Merge branch 'main' into bump_h2_2_4_240

This commit is contained in:
Ludy
2026-08-03 20:53:54 +02:00
committed by GitHub
556 changed files with 20474 additions and 5116 deletions
+2
View File
@@ -123,8 +123,10 @@ generated-models: &generated-models
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
- frontend/editor/src/core/types/toolIO.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
- engine/src/stirling/models/tool_io.py
- .taskfiles/frontend.yml
- .taskfiles/engine.yml
- .github/workflows/check-generated-models.yml
+7 -7
View File
@@ -35,7 +35,7 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -115,7 +115,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -184,7 +184,7 @@ jobs:
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get version number
id: versionNumber
@@ -193,7 +193,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -320,7 +320,7 @@ jobs:
# under the exact same access rules as the app preview.
- name: Detect Storybook changes
id: sb-changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
list-files: json
filters: |
@@ -331,7 +331,7 @@ jobs:
- name: Set up Node.js for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
@@ -456,7 +456,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -70,7 +70,7 @@ jobs:
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -183,7 +183,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -233,10 +233,10 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -474,7 +474,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
is_fork: ${{ steps.decide.outputs.is_fork }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -70,7 +70,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
flavor: [core, proprietary, saas]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
+69 -2
View File
@@ -50,7 +50,7 @@ jobs:
SYSTEM_ENABLEANALYTICS: "false"
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
@@ -61,7 +61,7 @@ jobs:
java-version: "25"
distribution: "temurin"
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
@@ -295,3 +295,70 @@ jobs:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
multinode-e2e:
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
always() && needs.pick.outputs.is_fork != 'true'
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 60
env:
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
PREMIUM_ENABLED: "true"
SYSTEM_ENABLEANALYTICS: "false"
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
MN_COMPOSE: docker-compose-multinode.yml
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Install behave test deps
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
- name: Build the multi-node image
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" build
- name: Bring up the cluster and wait for both nodes healthy
working-directory: testing/compose
run: |
docker compose -f "$MN_COMPOSE" up -d
for i in $(seq 1 90); do
h1=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null || echo starting)
h2=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-2 2>/dev/null || echo starting)
if [ "$h1" = healthy ] && [ "$h2" = healthy ]; then echo "both nodes healthy"; exit 0; fi
sleep 5
done
echo "::error::nodes did not become healthy"
docker compose -f "$MN_COMPOSE" logs --tail=200 stirling-1 stirling-2
exit 1
- name: Seed the cluster (teams, users, S3 connection, policy)
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" --profile seed run --rm seed
- name: Run multi-node regression (implemented guarantees)
working-directory: testing/cucumber
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
- name: Run multi-node failover (destructive)
working-directory: testing/cucumber
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
- name: Dump node logs on failure
if: failure()
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" logs --tail=400 stirling-1 stirling-2
- name: Tear down
if: always()
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" --profile seed down -v --remove-orphans
+7 -5
View File
@@ -48,14 +48,14 @@ jobs:
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: .github/config/.files.yaml
@@ -174,12 +174,14 @@ jobs:
pull-requests: write
uses: ./.github/workflows/tauri-build.yml
secrets: inherit
# PR smoke build: macOS + Windows (the platforms our developers use).
# PR smoke build: macOS + Windows (the platforms our developers use).
# sign: true only reaches macOS - tauri-build's per-platform gate keeps
# Windows/Linux signing on main, and an unsigned .dmg cannot be opened.
# The full signed multi-OS matrix runs on release;
# nightly still warms the Rust cache with all-OS defaults.
with:
platform: windows-macos
sign: false
sign: true
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
@@ -267,7 +269,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+15 -24
View File
@@ -1,12 +1,12 @@
name: Check generated models
# Verifies the committed generated API models are still in sync with the Java
# OpenAPI spec: the frontend tool API types
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
# single top-level `task tool-models` and fails if either committed file is
# out of date. Called from build.yml when the backend Java, frontend, or engine
# changes; also runs on push to main as a post-merge safety net.
# Verifies the committed generated files are still in sync with the Java OpenAPI
# spec: the request models (toolApiTypes.ts, tool_models.py) and the tool I/O
# tables saying what each endpoint accepts and produces (toolIO.ts, tool_io.py).
# Regenerates them all with the single top-level `task tool-models` and fails if
# any committed file is out of date. Called from build.yml when the
# backend Java, frontend, or engine changes; also runs on push to main as a
# post-merge safety net.
on:
workflow_call:
push:
@@ -23,7 +23,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -48,7 +48,7 @@ jobs:
gradle-version: 9.6.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
@@ -57,18 +57,10 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
# frontend types and the engine tool models from it.
- name: Regenerate generated models
run: task tool-models
- name: Verify generated models are up to date
id: models-check
continue-on-error: true
run: |
git diff --exit-code \
frontend/editor/src/core/types/toolApiTypes.ts \
engine/src/stirling/models/tool_models.py
run: task tool-models:check
- name: Comment on generated models check failure
# Only post a comment on PRs. github-script's PR helpers need an
@@ -83,9 +75,9 @@ jobs:
marker,
'### Generated Models Check Failed',
'',
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
'One or more generated files are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
'',
'Run `task tool-models` to regenerate both, then commit the updated files.',
'Run `task tool-models` to regenerate them, then commit the updated files.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
@@ -116,11 +108,10 @@ jobs:
echo " Generated Models Check Failed"
echo "============================================"
echo ""
echo "The generated frontend API types and/or engine tool"
echo "models are out of date with the Java OpenAPI spec and"
echo "will need to be regenerated before they can be merged in."
echo "One or more generated files are out of date with the Java"
echo "OpenAPI spec and will need to be regenerated before merging."
echo ""
echo "Run 'task tool-models' to regenerate both, then"
echo "Run 'task tool-models' to regenerate them, then"
echo "commit the updated files."
echo "============================================"
exit 1
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get commit hashes for frontend and backend
id: commit-hashes
@@ -85,7 +85,7 @@ jobs:
fi
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -61,7 +61,7 @@ jobs:
# runtime token isn't exposed) since the docker driver can't use it.
- name: Set up Docker Buildx
if: inputs.docker-base-changed != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
+44 -2
View File
@@ -15,7 +15,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
@@ -25,8 +25,41 @@ jobs:
with:
java-version: "25"
distribution: "temurin"
# Same cache layer as backend-build.yml. Without it every run resolved the
# whole classpath cold and eventually got HTTP 429 from Maven Central.
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
# Gradle does not retry 429s, and a cold cache resolving the buildscript
# classpath is exactly where Maven Central rate-limits us. Retry it here,
# where a failure is cheap, instead of inside the backgrounded bootRun.
- name: Prime Gradle dependencies
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: |
for attempt in 1 2 3; do
if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then
exit 0
fi
echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)"
sleep $((attempt * 30))
done
echo "::error::Gradle could not resolve dependencies after 3 attempts"
exit 1
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
@@ -53,6 +86,11 @@ jobs:
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
# Internal mirror, as in backend-build.yml. Empty on Dependabot and
# fork PRs, where the build falls back to Maven Central.
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: task e2e:live
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
@@ -66,6 +104,10 @@ jobs:
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
# `if: always()` so even a failed test run still produces a
# report from whatever flows did exercise the backend before
# the failure. The task itself tolerates a missing .exec
+2 -2
View File
@@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
@@ -35,7 +35,7 @@ jobs:
# Need the base branch too, to diff against it.
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
@@ -28,7 +28,7 @@ jobs:
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: .github/config/.files.yaml
@@ -52,7 +52,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -80,7 +80,7 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
@@ -326,7 +326,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+2 -2
View File
@@ -15,13 +15,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+6 -6
View File
@@ -44,7 +44,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -127,7 +127,7 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -146,7 +146,7 @@ jobs:
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "npm"
@@ -191,7 +191,7 @@ jobs:
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
allowed-endpoints: >
@@ -208,7 +208,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "npm"
@@ -637,7 +637,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+4 -4
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
@@ -64,7 +64,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -72,7 +72,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -79,7 +79,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
pull-requests: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+4 -4
View File
@@ -43,20 +43,20 @@ jobs:
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -64,7 +64,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
+4 -4
View File
@@ -53,7 +53,7 @@ jobs:
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -82,7 +82,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -107,13 +107,13 @@ jobs:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -26,13 +26,13 @@ jobs:
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v3.29.5
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3.29.5
with:
sarif_file: results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -50,7 +50,7 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: "npm"
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+26 -12
View File
@@ -62,7 +62,7 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -108,9 +108,14 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
# Per-platform sign gate. macOS signs on any run with the cert available,
# PRs included: Gatekeeper blocks an unsigned .dmg, so an unsigned macOS
# PR build is not testable. Windows and Linux stay main-only, matching the
# gates on their own signing steps below.
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -124,7 +129,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "npm"
@@ -274,7 +279,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -295,7 +300,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -368,7 +373,7 @@ jobs:
fi
- name: Build Tauri app (signed)
if: inputs.sign
if: env.SIGN_BUNDLE == 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -403,7 +408,7 @@ jobs:
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
if: env.SIGN_BUNDLE != 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -420,7 +425,9 @@ jobs:
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
args: >-
${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
--config '{"bundle":{"createUpdaterArtifacts":false}}'
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
@@ -456,7 +463,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: inputs.sign && matrix.platform == 'macos-15'
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
@@ -613,12 +620,19 @@ jobs:
pr-comment:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && needs.build.result == 'success'
# Fork and Dependabot pull_request runs receive a read-only GITHUB_TOKEN,
# so the API cannot create or update PR comments there. The artifacts are
# still uploaded and remain available from the Actions run page.
if: >-
github.event_name == 'pull_request' &&
needs.build.result == 'success' &&
!github.event.pull_request.head.repo.fork &&
github.actor != 'dependabot[bot]'
permissions:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -709,7 +723,7 @@ jobs:
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+5 -5
View File
@@ -53,7 +53,7 @@ jobs:
cache-scope: stirling-pdf-fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -61,7 +61,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -115,7 +115,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build base image locally (PR base change only)
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
@@ -195,7 +195,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -207,7 +207,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build docker/unoserver/Dockerfile
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+8 -8
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -52,7 +52,7 @@ jobs:
DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get version number
id: versionNumber
@@ -61,7 +61,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -131,14 +131,14 @@ jobs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: ".github/config/.files.yaml"
@@ -149,14 +149,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -185,7 +185,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+9
View File
@@ -114,6 +114,15 @@ tasks:
- cmd: ./gradlew test
platforms: [linux, darwin]
test:force:
desc: "Run backend tests, ignoring cached results"
aliases: [test:no-cache]
cmds:
- cmd: cmd /c ".\gradlew.bat cleanTest test --no-build-cache"
platforms: [windows]
- cmd: ./gradlew cleanTest test --no-build-cache
platforms: [linux, darwin]
format:
desc: "Auto-fix code formatting"
cmds:
+8 -1
View File
@@ -102,12 +102,19 @@ tasks:
desc: "Generate tool_models.py from Java OpenAPI spec (SwaggerDoc.json)"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
sources:
- ../SwaggerDoc.json
- scripts/generate_tool_models.py
generates:
- src/stirling/models/tool_models.py
- src/stirling/models/tool_io.py
tool-models:check:
desc: "Fail if the committed tool models are out of date"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
clean:
desc: "Clean build artifacts"
+21 -2
View File
@@ -263,6 +263,16 @@ tasks:
- task: lint:eslint
- task: lint:dpdm
- task: lint:colors
- task: lint:css
lint:css:
desc: "Lint stylesheets for duplicate selectors"
deps: [install]
cmds:
# Covers the whole editor tree, including the portal/processor layer and
# public/css. Vendored CSS and build output are excluded via ignoreFiles
# in stylelint.config.mjs.
- npx stylelint "editor/**/*.css"
lint:colors:
desc: "Enforce theme tokens — no hardcoded colours or raw primitives in components"
@@ -388,6 +398,13 @@ tasks:
- task: typecheck:_run
vars: { PROJECT: editor/src/portal/tsconfig.json }
typecheck:storybook:
desc: "Typecheck Storybook config and stories"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: .storybook/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
cmds:
@@ -399,6 +416,7 @@ tasks:
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- task: typecheck:storybook
# ============================================================
# Quality Gate
@@ -480,18 +498,19 @@ tasks:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
- editor/src/core/types/toolIO.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
licenses:generate:
desc: "Generate frontend license report"
+34 -1
View File
@@ -504,7 +504,8 @@ For Stirling 2.0, new features are built as React components:
1. **Create a New Controller:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates.")`.
- If the endpoint transforms a document, declare what it accepts and produces with `@ToolIO`, for example `@ToolIO(produces = ToolFormat.PDF)`. This is what lets a pipeline containing the step be checked before it runs, so a chain that cannot work is caught in the builder rather than part-way through a job. Endpoints under the tool namespaces are required to carry it - `ToolIODeclarationCoverageTest` fails the build otherwise. See [Declaring tool inputs and outputs](#declaring-tool-inputs-and-outputs).
```java
package stirling.software.SPDF.controller.api;
@@ -578,6 +579,38 @@ For Stirling 2.0, new features are built as React components:
}
```
### Declaring tool inputs and outputs
An endpoint that transforms a document declares what it accepts and produces with `@ToolIO`. This is the single source of truth: it is published into the OpenAPI spec as an `x-stirling-io` extension, and generated from there into the frontend (`toolIO.ts`) and the AI engine (`tool_io.py`). A pipeline can therefore be checked while it is being edited, instead of failing part-way through a job.
```java
@ToolIO(produces = ToolFormat.PDF)
```
`accepts` defaults to `{ ToolFormat.PDF }` and `arity` to `ToolArity.SISO`, so most tools only declare what they produce.
- **`ToolFormat`** is the kind of file: `PDF`, `PDF_ENCRYPTED`, `IMAGE`, `ZIP`, `WORD`, `PPT`, `EXCEL`, `CSV`, `HTML`, `XML`, `JSON`, `TEXT`, `MARKDOWN`, `JAVASCRIPT`, `EBOOK`, `EMAIL`, `POSTSCRIPT`, `VIDEO`, `CBZ`, `CBR`, plus `ANY` (accepts or produces anything) and `NONE` (returns a report, not a file). Encryption is a format rather than a flag, so the default `accepts = PDF` means an endpoint rejects an encrypted PDF unless it opts in.
- **`ToolArity`** is how many files go in and out: `SISO`, `SIMO`, `MISO`, `MIMO`. This axis carries ZIP-as-transport. A splitter is `produces = PDF, arity = SIMO`, and the caller unpacks the archive; an endpoint whose deliverable really is an archive declares `produces = ZIP` with a single-output arity and stays packed.
When the output depends on a parameter, declare the exception as a case rather than picking one answer. Add Password produces an encrypted PDF unless both passwords are blank, in which case it has only set permissions:
```java
@ToolIO(
produces = ToolFormat.PDF_ENCRYPTED,
cases =
@ToolIOCase(
when = {
@ToolIOWhen(param = "password", matches = ""),
@ToolIOWhen(param = "ownerPassword", matches = "")
},
produces = ToolFormat.PDF,
arity = ToolArity.SISO))
```
Every condition in a `when` must hold for the case to apply, and `matches` is compared as a string, case-insensitively, with an empty string matching an absent or blank value. If a case reads a parameter that is not set yet, the output is reported as uncertain and the chain warns rather than erroring.
Endpoints under the tool namespaces must carry a declaration; `ToolIODeclarationCoverageTest` fails the build for any that does not, with a short allowlist for endpoints that manage a session, a device or a stored resource rather than transforming a document. The matching rules are implemented three times (Java `ToolChainValidator`, `toolIOCompat.ts`, `tool_io_compat.py`) and pinned to the same answers by the shared fixtures in `testing/tool-io-cases.json`, so a behaviour change belongs in that file first.
## Adding New Translations to Existing Language Files in Stirling-PDF
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
+6
View File
@@ -194,6 +194,12 @@ tasks:
- task: frontend:tool-models
- task: engine:tool-models
tool-models:check:
desc: "Fail if any committed API model is out of date"
cmds:
- task: frontend:tool-models:check
- task: engine:tool-models:check
# ============================================================
# Quality Gate
# ============================================================
+12
View File
@@ -32,6 +32,14 @@
"moduleName": ".*",
"moduleLicense": "BSD-4 License"
},
{
"moduleName": ".*",
"moduleLicense": "Revised BSD"
},
{
"moduleName": ".*",
"moduleLicense": "ISC"
},
{
"moduleName": ".*",
"moduleLicense": "MIT"
@@ -48,6 +56,10 @@
"moduleName": ".*",
"moduleLicense": "MIT-0"
},
{
"moduleName": ".*",
"moduleLicense": "MIT license"
},
{
"moduleName": "com.github.jai-imageio:jai-imageio-core",
"moduleLicense": "LICENSE.txt"
@@ -338,6 +338,7 @@ public class EndpointConfiguration {
addEndpointToGroup("PageOps", "split-pages");
addEndpointToGroup("PageOps", "rearrange-pages");
addEndpointToGroup("PageOps", "rotate-pdf");
addEndpointToGroup("PageOps", "auto-rotate-pdf");
addEndpointToGroup("PageOps", "multi-page-layout");
addEndpointToGroup("PageOps", "booklet-imposition");
addEndpointToGroup("PageOps", "scale-pages");
@@ -0,0 +1,105 @@
package stirling.software.common.config.swagger;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
import org.springdoc.core.customizers.GlobalOperationCustomizer;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOWhen;
/**
* Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend
* and the AI engine get it.
*
* <p>Also appends the {@code Input:/Output:/Type:} line the docs used to carry by hand, so the
* published text is unchanged without anyone maintaining it.
*/
@Component
public class ToolIOOperationCustomizer
implements GlobalOperationCustomizer, GlobalOpenApiCustomizer {
public static final String EXTENSION_NAME = "x-stirling-io";
public static final String VOCABULARY_EXTENSION_NAME = "x-stirling-io-vocabulary";
// Published separately from the declarations: generators need the full vocabulary for their
// enums, and deriving it from what is present would shrink it when an endpoint is disabled.
@Override
public void customise(OpenAPI openApi) {
Map<String, Object> vocabulary = new LinkedHashMap<>();
vocabulary.put("formats", names(ToolFormat.values()));
vocabulary.put("arities", names(ToolArity.values()));
openApi.addExtension(VOCABULARY_EXTENSION_NAME, vocabulary);
}
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
ToolIO declaration = handlerMethod.getMethodAnnotation(ToolIO.class);
if (declaration == null) {
return operation;
}
operation.addExtension(EXTENSION_NAME, toExtension(declaration));
operation.setDescription(appendSummaryLine(operation.getDescription(), declaration));
return operation;
}
private static Map<String, Object> toExtension(ToolIO declaration) {
Map<String, Object> extension = new LinkedHashMap<>();
extension.put("accepts", names(declaration.accepts()));
extension.put("produces", declaration.produces().name());
extension.put("arity", declaration.arity().name());
if (declaration.cases().length > 0) {
extension.put("cases", cases(declaration));
}
return extension;
}
private static List<Map<String, Object>> cases(ToolIO declaration) {
return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList();
}
private static Map<String, Object> toCase(ToolIOCase rule) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put(
"when",
Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList());
entry.put("produces", rule.produces().name());
entry.put("arity", rule.arity().name());
return entry;
}
private static Map<String, Object> toCondition(ToolIOWhen condition) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("param", condition.param());
entry.put("matches", List.of(condition.matches()));
return entry;
}
private static List<String> names(Enum<?>[] values) {
return Arrays.stream(values).map(Enum::name).toList();
}
private static String appendSummaryLine(String description, ToolIO declaration) {
String summary =
"Input:"
+ String.join("/", names(declaration.accepts()))
+ " Output:"
+ declaration.produces().name()
+ " Type:"
+ declaration.arity().name();
return description == null || description.isBlank()
? summary
: description.trim() + " " + summary;
}
}
@@ -251,6 +251,21 @@ public class ApplicationProperties {
*/
private boolean allowPrivateS3Endpoints = false;
/**
* Whether a network source's host (SFTP, FTP, or SMB) may resolve to a loopback,
* link-local, or private address. Off by default so a connection cannot be pointed at
* internal services; enable for an on-network file server (e.g. an internal SFTP drop or a
* Samba share).
*/
private boolean allowPrivateNetworkSources = false;
/**
* Hostnames (exact, case-insensitive) that a network source may use even when they resolve
* to a private or local address and {@code allowPrivateNetworkSources} is off. Lets shared
* infra allow one named on-prem file server without opening every internal host.
*/
private List<String> allowedPrivateNetworkHosts = new java.util.ArrayList<>();
/**
* Whether an API/Purview/ConsignO integration's base URL may resolve to a loopback,
* link-local, or private address. Off by default: unlike S3 connections, any user may
@@ -1076,6 +1091,20 @@ public class ApplicationProperties {
private Quotas quotas = new Quotas();
private Sharing sharing = new Sharing();
private Signing signing = new Signing();
private Encryption encryption = new Encryption();
/**
* Encryption at rest for stored files (Pro/Enterprise). Enabling encrypts new writes;
* disabling later only stops encrypting new writes — existing encrypted files keep
* decrypting as long as the key material is present. The master key is resolved like the
* credential key: {@code stirling.security.fileEncryptionKey} property, {@code
* STIRLING_FILE_ENCRYPTION_KEY} env var, or an auto-generated {@code file-encryption.key}
* in the config directory.
*/
@Data
public static class Encryption {
private boolean enabled = false;
}
@Data
public static class Local {
@@ -0,0 +1,24 @@
package stirling.software.common.model.tool;
/**
* How many files an endpoint consumes and produces (Single/Multiple In, Single/Multiple Out).
*
* <p>This axis carries ZIP-as-transport: a multi-output endpoint returns its results zipped and the
* caller unpacks them, so {@code split-pages} is {@code produces = PDF, arity = SIMO} rather than
* naming a ZIP-of-PDF format. An endpoint whose deliverable really is an archive declares {@link
* ToolFormat#ZIP} with a single-output arity and stays packed.
*/
public enum ToolArity {
SISO,
SIMO,
MISO,
MIMO;
public boolean isMultiInput() {
return this == MISO || this == MIMO;
}
public boolean isMultiOutput() {
return this == SIMO || this == MIMO;
}
}
@@ -0,0 +1,47 @@
package stirling.software.common.model.tool;
/**
* One problem found while checking a chain, against the step that cannot run. {@code code} is
* stable so the frontend can pick its own wording; {@code message} is an English fallback.
*/
public record ToolDiagnostic(int stepIndex, Severity severity, String code, String message) {
public enum Severity {
/** The chain cannot run as configured. Only this should block a save. */
ERROR,
/** May not run, depending on configuration or file content. */
WARN,
/** Worth knowing but not a problem, such as a step running once per file. */
INFO
}
/** The step declares no {@link ToolIO}, so nothing past it can be checked. */
public static final String UNDECLARED = "undeclared-operation";
/** The previous step's output is not a format this step accepts. */
public static final String FORMAT_MISMATCH = "format-mismatch";
/** The previous step's output depends on a parameter that is not set yet. */
public static final String OUTPUT_UNCERTAIN = "output-uncertain";
/** The pipeline's input files are not a format the first step accepts. */
public static final String SOURCE_MISMATCH = "source-mismatch";
/** The previous step emits several files and this one runs once per file. */
public static final String FAN_OUT = "fan-out";
/** The previous step emits several files and this one consumes them in a single call. */
public static final String FAN_IN = "fan-in";
public static ToolDiagnostic error(int stepIndex, String code, String message) {
return new ToolDiagnostic(stepIndex, Severity.ERROR, code, message);
}
public static ToolDiagnostic warn(int stepIndex, String code, String message) {
return new ToolDiagnostic(stepIndex, Severity.WARN, code, message);
}
public static ToolDiagnostic info(int stepIndex, String code, String message) {
return new ToolDiagnostic(stepIndex, Severity.INFO, code, message);
}
}
@@ -0,0 +1,62 @@
package stirling.software.common.model.tool;
import java.util.List;
import lombok.Getter;
/**
* The kind of file a tool endpoint consumes or produces.
*
* <p>Encryption is its own format rather than a separate attribute, so the endpoints accepting only
* {@link #PDF} reject an encrypted one without declaring anything.
*
* <p>Extensions are a lossy projection used for run-time file checks: {@link #PDF} and {@link
* #PDF_ENCRYPTED} share {@code pdf}, because a filename cannot tell you whether a PDF is encrypted.
*/
@Getter
public enum ToolFormat {
PDF("pdf"),
PDF_ENCRYPTED("pdf"),
// Vector formats are folded in: the extension set has always included svg/eps, and splitting
// them out would make chains that run fine today report as broken.
IMAGE("png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "psd", "ai", "eps"),
/**
* An archive that is itself the deliverable. Multiple results use {@link ToolArity} instead.
*/
ZIP("zip", "rar", "7z", "tar", "gz", "bz2", "xz", "lz", "lzma", "z"),
WORD("doc", "docx", "odt", "rtf"),
PPT("ppt", "pptx", "odp"),
EXCEL("xls", "xlsx", "ods"),
CSV("csv"),
HTML("html", "htm", "xhtml"),
XML("xml", "xsd", "xsl"),
JSON("json"),
TEXT("txt", "text", "md", "markdown"),
MARKDOWN("md", "markdown"),
JAVASCRIPT("js", "jsx"),
EBOOK("epub", "mobi", "azw3", "fb2", "txt", "docx"),
EMAIL("eml", "msg"),
POSTSCRIPT("ps", "eps"),
PCL("pcl", "pxl"),
XPS("xps", "oxps"),
VIDEO("mp4", "webm", "avi", "mov", "mkv"),
CBZ("cbz"),
CBR("cbr"),
/** Never reported as incompatible. */
ANY(),
/** A report or a status rather than a document. */
NONE();
private final List<String> extensions;
ToolFormat(String... extensions) {
this.extensions = List.of(extensions);
}
}
@@ -0,0 +1,30 @@
package stirling.software.common.model.tool;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* What a tool endpoint consumes and produces, so a chain of steps can be checked before it runs.
*
* <p>The single source of truth: read off the handler method by {@code ToolIORegistry}, published
* into the OpenAPI spec as {@code x-stirling-io}, and generated from there into the frontend and
* the AI engine.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ToolIO {
/** Defaulting to a plain PDF is what makes an ordinary endpoint reject an encrypted one. */
ToolFormat[] accepts() default {ToolFormat.PDF};
ToolFormat produces();
ToolArity arity() default ToolArity.SISO;
/** Overrides for an output that depends on a parameter; first match wins. */
ToolIOCase[] cases() default {};
}
@@ -0,0 +1,22 @@
package stirling.software.common.model.tool;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* An output that applies when every condition in {@link #when()} holds.
*
* <p>Conditions are ANDed because the interesting branches turn on more than one parameter: Add
* Password only leaves the document unencrypted when both passwords are absent.
*/
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ToolIOCase {
ToolIOWhen[] when();
ToolFormat produces();
ToolArity arity();
}
@@ -0,0 +1,19 @@
package stirling.software.common.model.tool;
import java.util.Map;
import java.util.Optional;
/**
* Supplies the {@link ToolIO} declaration for an endpoint path. An interface so a chain can be
* checked against a fixed set of declarations without standing up an application context.
*/
@FunctionalInterface
public interface ToolIOSource {
Optional<ToolIOSpec> find(String operationPath);
static ToolIOSource of(Map<String, ToolIOSpec> specs) {
Map<String, ToolIOSpec> copy = Map.copyOf(specs);
return path -> Optional.ofNullable(copy.get(path));
}
}
@@ -0,0 +1,108 @@
package stirling.software.common.model.tool;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */
public record ToolIOSpec(
Set<ToolFormat> accepts, ToolFormat produces, ToolArity arity, List<Case> cases) {
public record When(String param, List<String> matches) {
boolean holdsFor(Object value) {
String normalised = normalise(value);
return matches.stream().anyMatch(match -> normalise(match).equals(normalised));
}
}
/**
* Both sides of a condition are normalised at comparison, not at construction: the declaration
* reaches the frontend and the engine as published data, and normalising only one side there
* would silently disagree with this one.
*/
public static String normalise(Object value) {
return value == null ? "" : String.valueOf(value).trim().toLowerCase(Locale.ROOT);
}
public record Case(List<When> when, ToolFormat produces, ToolArity arity) {
public Case {
when = List.copyOf(when);
}
}
/** {@code certain} is false when a {@link Case} keys on a parameter whose value is unknown. */
public record Output(ToolFormat format, ToolArity arity, boolean certain) {}
public ToolIOSpec {
accepts = Set.copyOf(accepts);
cases = List.copyOf(cases);
}
public static ToolIOSpec from(ToolIO annotation) {
return new ToolIOSpec(
new LinkedHashSet<>(Arrays.asList(annotation.accepts())),
annotation.produces(),
annotation.arity(),
Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList());
}
private static Case toCase(ToolIOCase rule) {
List<When> when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList();
return new Case(when, rule.produces(), rule.arity());
}
private static When toWhen(ToolIOWhen condition) {
return new When(condition.param(), List.of(condition.matches()));
}
/**
* First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the
* declared output comes back uncertain: a value we never saw might have picked another branch.
*
* @param parameters the step's configured parameters, or null when not known
*/
public Output resolveOutput(Map<String, Object> parameters) {
boolean sawUnknownParam = false;
for (Case rule : cases) {
boolean allHold = true;
for (When condition : rule.when()) {
if (parameters == null || !parameters.containsKey(condition.param())) {
sawUnknownParam = true;
allHold = false;
continue;
}
allHold &= condition.holdsFor(parameters.get(condition.param()));
}
if (allHold) {
return new Output(rule.produces(), rule.arity(), true);
}
}
return new Output(produces, arity, !sawUnknownParam);
}
public Output resolveOutput() {
return resolveOutput(null);
}
public boolean acceptsFormat(ToolFormat format) {
return format == ToolFormat.ANY
|| accepts.contains(ToolFormat.ANY)
|| accepts.contains(format);
}
/** For run-time file checks. Empty means anything is accepted. */
public List<String> acceptedExtensions() {
if (accepts.contains(ToolFormat.ANY)) {
return List.of();
}
return accepts.stream()
.flatMap(format -> format.getExtensions().stream())
.distinct()
.toList();
}
}
@@ -0,0 +1,18 @@
package stirling.software.common.model.tool;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** One condition on a request parameter, guarding a {@link ToolIOCase}. */
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ToolIOWhen {
String param();
/**
* Compared as strings, case-insensitively. An empty string matches an absent or blank value.
*/
String[] matches();
}
@@ -0,0 +1,168 @@
package stirling.software.common.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.tool.ToolDiagnostic;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIOSource;
import stirling.software.common.model.tool.ToolIOSpec;
/**
* Whether a chain of steps can run: what each produces against what the next accepts.
*
* <p>The frontend and the AI engine implement the same rules against their generated copies, so a
* chain can be checked without a round trip. {@code testing/tool-io-cases.json} pins all three to
* the same answers.
*/
@Service
@RequiredArgsConstructor
public class ToolChainValidator {
/** {@code parameters} may be null; only used to resolve an output that depends on one. */
public record Step(String operation, Map<String, Object> parameters) {}
private final ToolIOSource toolIO;
public List<ToolDiagnostic> validate(List<Step> steps) {
return validate(steps, null);
}
/**
* @param sourceFormat the format entering step one, or null when unknown
*/
public List<ToolDiagnostic> validate(List<Step> steps, ToolFormat sourceFormat) {
List<ToolDiagnostic> diagnostics = new ArrayList<>();
ToolIOSpec.Output carried = null;
for (int i = 0; i < steps.size(); i++) {
Step step = steps.get(i);
Optional<ToolIOSpec> found = toolIO.find(step.operation());
if (found.isEmpty()) {
diagnostics.add(
ToolDiagnostic.warn(
i,
ToolDiagnostic.UNDECLARED,
"Step "
+ step.operation()
+ " does not declare what it accepts or produces, so the"
+ " rest of the chain cannot be checked."));
// Nothing is known past an undeclared step.
carried = null;
continue;
}
ToolIOSpec spec = found.get();
// Only the first step is handed the pipeline's input. Every later step is handed the
// previous step's output, which is simply unknown once an undeclared step intervened -
// checking it against the input again would judge it on a format it never receives.
if (i == 0) {
checkSource(diagnostics, i, step, spec, sourceFormat);
} else if (carried != null) {
checkTransition(diagnostics, i, step, spec, carried);
}
carried = spec.resolveOutput(step.parameters());
}
return diagnostics;
}
public static boolean hasErrors(List<ToolDiagnostic> diagnostics) {
return diagnostics.stream().anyMatch(d -> d.severity() == ToolDiagnostic.Severity.ERROR);
}
private static void checkSource(
List<ToolDiagnostic> diagnostics,
int index,
Step step,
ToolIOSpec spec,
ToolFormat sourceFormat) {
if (sourceFormat == null || spec.acceptsFormat(sourceFormat)) {
return;
}
diagnostics.add(
ToolDiagnostic.error(
index,
ToolDiagnostic.SOURCE_MISMATCH,
"Step "
+ step.operation()
+ " accepts "
+ describe(spec)
+ " but the pipeline's input is "
+ sourceFormat
+ "."));
}
private static void checkTransition(
List<ToolDiagnostic> diagnostics,
int index,
Step step,
ToolIOSpec spec,
ToolIOSpec.Output previous) {
if (previous.format() == ToolFormat.NONE) {
diagnostics.add(
ToolDiagnostic.error(
index,
ToolDiagnostic.FORMAT_MISMATCH,
"The previous step returns a report rather than a file, so "
+ step.operation()
+ " has nothing to run on."));
return;
}
if (!spec.acceptsFormat(previous.format())) {
String message =
"Step "
+ step.operation()
+ " accepts "
+ describe(spec)
+ " but the previous step produces "
+ previous.format()
+ ".";
diagnostics.add(
previous.certain()
? ToolDiagnostic.error(index, ToolDiagnostic.FORMAT_MISMATCH, message)
// Unresolved output: may yet be fine once the step is configured.
: ToolDiagnostic.warn(index, ToolDiagnostic.OUTPUT_UNCERTAIN, message));
return;
}
if (!previous.certain()) {
diagnostics.add(
ToolDiagnostic.warn(
index,
ToolDiagnostic.OUTPUT_UNCERTAIN,
"The previous step's output depends on how it is configured, so this"
+ " step may not be able to run."));
return;
}
if (previous.arity().isMultiOutput()) {
diagnostics.add(
spec.arity().isMultiInput()
? ToolDiagnostic.info(
index,
ToolDiagnostic.FAN_IN,
"This step combines every file the previous step produced.")
: ToolDiagnostic.info(
index,
ToolDiagnostic.FAN_OUT,
"This step runs once for each file the previous step"
+ " produced."));
}
}
private static String describe(ToolIOSpec spec) {
return spec.accepts().stream()
.map(Enum::name)
.sorted()
.reduce((a, b) -> a + " or " + b)
.orElse("nothing");
}
}
@@ -0,0 +1,127 @@
package stirling.software.common.service;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOSource;
import stirling.software.common.model.tool.ToolIOSpec;
/**
* Reads every {@link ToolIO} declaration off its handler method at startup and serves it by
* endpoint path. Replaces parsing the same information out of the description prose, which meant
* fetching our own {@code /v1/api-docs} over HTTP first.
*/
@Slf4j
@Service
public class ToolIORegistry implements ToolMetadataService, ToolIOSource {
private final ApplicationContext applicationContext;
// Written on the startup thread, read on request threads. Spring's lifecycle establishes
// happens-before, so no volatile (same as AiEngineEndpointResolver).
private Map<String, ToolIOSpec> specsByPath = Map.of();
// Keep this the only constructor: with two, Spring falls back to a no-arg one that isn't here.
public ToolIORegistry(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
/** A registry over known declarations rather than ones discovered from the context. */
static ToolIORegistry forSpecs(Map<String, ToolIOSpec> specs) {
ToolIORegistry registry = new ToolIORegistry(null);
registry.specsByPath = Map.copyOf(specs);
return registry;
}
@EventListener(ContextRefreshedEvent.class)
public void discoverToolIO() {
Map<String, ToolIOSpec> discovered = new TreeMap<>();
for (RequestMappingHandlerMapping mapping :
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
mapping.getHandlerMethods()
.forEach((info, handler) -> register(discovered, info, handler));
}
specsByPath = Map.copyOf(discovered);
log.debug("Discovered {} endpoints declaring @ToolIO", specsByPath.size());
}
private static void register(
Map<String, ToolIOSpec> target, RequestMappingInfo info, HandlerMethod handler) {
ToolIO annotation = handler.getMethodAnnotation(ToolIO.class);
if (annotation == null) {
return;
}
ToolIOSpec spec = ToolIOSpec.from(annotation);
for (String pattern : extractPatterns(info)) {
target.put(pattern, spec);
}
}
@Override
public Optional<ToolIOSpec> find(String operationPath) {
return Optional.ofNullable(specsByPath.get(operationPath));
}
@Override
public boolean isMultiInput(String operationPath) {
return find(operationPath).map(spec -> spec.arity().isMultiInput()).orElse(false);
}
@Override
public List<String> getExtensionTypes(boolean output, String operationPath) {
Optional<ToolIOSpec> spec = find(operationPath);
if (spec.isEmpty()) {
return null;
}
List<String> extensions =
output
? spec.get().resolveOutput().format().getExtensions()
: spec.get().acceptedExtensions();
// Callers express "no restriction" as null.
return extensions.isEmpty() ? null : extensions;
}
@Override
public boolean shouldUnpackZipResponse(String operationPath) {
// Multi-output zips purely as transport. A single-output ZIP is the deliverable
// (extract-attachments) and stays packed.
return find(operationPath)
.map(spec -> spec.resolveOutput().arity().isMultiOutput())
.orElse(false);
}
private static Set<String> extractPatterns(RequestMappingInfo info) {
try {
Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
Object result = getDirectPaths.invoke(info);
if (result instanceof Set<?> set) {
Set<String> patterns = new HashSet<>();
for (Object value : set) {
if (value instanceof String s) {
patterns.add(s);
}
}
return patterns;
}
} catch (Exception e) {
log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
}
return Set.of();
}
}
@@ -17,12 +17,12 @@ public interface ToolMetadataService {
List<String> getExtensionTypes(boolean output, String operationPath);
/**
* Returns true when the endpoint's ZIP response is a transport for multiple typed results and
* should be unpacked: multi-output endpoints (Type:SIMO / Type:MIMO) and wrapper declarations
* such as {@code Output:ZIP-PDF} or {@code Output:IMAGE/ZIP}.
* Returns true when the endpoint's ZIP response is a transport for several results and should
* be unpacked, which is exactly the multi-output endpoints (a {@code SIMO} or {@code MIMO}
* arity).
*
* <p>Returns false for a bare {@code Output:ZIP} (e.g. {@code get-attachments}), where the
* archive itself is the deliverable and should be kept packed.
* <p>Returns false for an endpoint whose declared output is an archive in its own right (for
* example {@code extract-attachments}), where unpacking would discard the deliverable.
*/
boolean shouldUnpackZipResponse(String operationPath);
}
@@ -537,10 +537,6 @@ public final class RegexPatternUtils {
getPattern("[/\\\\?%*:|\"<>]"); // Unsafe filename characters
getPattern("[^a-zA-Z0-9 ]"); // Input sanitization
getPattern("[^a-zA-Z0-9]"); // Filename sanitization
// API doc patterns
getPattern("Output:\\s*(\\w+)");
getPattern("Input:\\s*(\\w+)");
getPattern("Type:\\s*(\\w+)");
log.debug("Pre-compiled {} common regex patterns", patternCache.size());
}
@@ -550,23 +546,6 @@ public final class RegexPatternUtils {
"^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*(?:\\.[A-Za-z]{2,})$");
}
/* Pattern for matching Output:<TYPE> in API descriptions */
public Pattern getApiDocOutputTypePattern() {
return getPattern("Output:\\s*(\\w+)");
}
/* Pattern for matching Input:<TYPE> in API descriptions */
public Pattern getApiDocInputTypePattern() {
return getPattern("Input:\\s*(\\w+)");
}
/**
* Pattern for matching Type:<CODE> in API descriptions
*/
public Pattern getApiDocTypePattern() {
return getPattern("Type:\\s*(\\w+)");
}
/* Pattern for validating file extensions (2-4 alphanumeric, case-insensitive) */
public Pattern getFileExtensionValidationPattern() {
return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE);
@@ -0,0 +1,159 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolDiagnostic;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIOSource;
import stirling.software.common.model.tool.ToolIOSpec;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/** The shared cases in {@code testing/tool-io-cases.json}, which all three implementations run. */
class ToolChainValidatorConformanceTest {
private static final JsonMapper MAPPER = JsonMapper.builder().build();
@TestFactory
Stream<DynamicTest> sharedCases() throws IOException {
JsonNode root = MAPPER.readTree(Files.readString(casesFile()));
Map<String, ToolIOSpec> specs = readSpecs(root.get("specs"));
List<DynamicTest> tests = new ArrayList<>();
for (JsonNode testCase : root.get("cases")) {
tests.add(
DynamicTest.dynamicTest(
testCase.get("name").asString(), () -> runCase(testCase, specs)));
}
return tests.stream();
}
private static void runCase(JsonNode testCase, Map<String, ToolIOSpec> specs) {
Map<String, ToolIOSpec> registry = new HashMap<>();
List<ToolChainValidator.Step> steps = new ArrayList<>();
int index = 0;
for (JsonNode stepNode : testCase.get("steps")) {
// Each step gets its own path so the same spec can appear twice in a chain.
String operation = "/op/" + index++;
JsonNode specName = stepNode.get("spec");
if (specName != null && !specName.isNull()) {
registry.put(operation, specs.get(specName.asString()));
}
steps.add(new ToolChainValidator.Step(operation, readParameters(stepNode)));
}
JsonNode sourceNode = testCase.get("sourceFormat");
ToolFormat sourceFormat =
sourceNode == null || sourceNode.isNull()
? null
: ToolFormat.valueOf(sourceNode.asString());
List<ToolDiagnostic> actual =
new ToolChainValidator(ToolIOSource.of(registry)).validate(steps, sourceFormat);
assertEquals(summarise(testCase.get("expected")), summarise(actual), describe(actual));
}
private static Map<String, Object> readParameters(JsonNode stepNode) {
JsonNode parameters = stepNode.get("parameters");
if (parameters == null || parameters.isNull()) {
return null;
}
Map<String, Object> values = new HashMap<>();
parameters.propertyStream().forEach(e -> values.put(e.getKey(), e.getValue().asString()));
return values;
}
private static Map<String, ToolIOSpec> readSpecs(JsonNode node) {
Map<String, ToolIOSpec> specs = new HashMap<>();
node.propertyStream()
.forEach(entry -> specs.put(entry.getKey(), readSpec(entry.getValue())));
return specs;
}
private static ToolIOSpec readSpec(JsonNode node) {
Set<ToolFormat> accepts = new LinkedHashSet<>();
for (JsonNode format : node.get("accepts")) {
accepts.add(ToolFormat.valueOf(format.asString()));
}
List<ToolIOSpec.Case> cases = new ArrayList<>();
for (JsonNode rule : node.get("cases")) {
List<ToolIOSpec.When> when = new ArrayList<>();
for (JsonNode condition : rule.get("when")) {
List<String> matches = new ArrayList<>();
for (JsonNode match : condition.get("matches")) {
matches.add(match.asString());
}
when.add(new ToolIOSpec.When(condition.get("param").asString(), matches));
}
cases.add(
new ToolIOSpec.Case(
when,
ToolFormat.valueOf(rule.get("produces").asString()),
ToolArity.valueOf(rule.get("arity").asString())));
}
return new ToolIOSpec(
accepts,
ToolFormat.valueOf(node.get("produces").asString()),
ToolArity.valueOf(node.get("arity").asString()),
cases);
}
/** Messages are free text, so compare only the contractual parts. */
private static List<String> summarise(List<ToolDiagnostic> diagnostics) {
return diagnostics.stream()
.map(d -> d.stepIndex() + ":" + d.severity() + ":" + d.code())
.toList();
}
private static List<String> summarise(JsonNode expected) {
List<String> summary = new ArrayList<>();
for (JsonNode node : expected) {
summary.add(
node.get("stepIndex").asInt()
+ ":"
+ node.get("severity").asString()
+ ":"
+ node.get("code").asString());
}
return summary;
}
private static String describe(List<ToolDiagnostic> actual) {
return actual.stream()
.map(ToolDiagnostic::message)
.reduce((a, b) -> a + " | " + b)
.orElse("no diagnostics");
}
/** Shared with the frontend and engine, so it lives at the repo root. */
private static Path casesFile() {
Path current = Path.of("").toAbsolutePath();
while (current != null) {
Path candidate = current.resolve("testing/tool-io-cases.json");
if (Files.exists(candidate)) {
return candidate;
}
current = current.getParent();
}
throw new IllegalStateException(
"testing/tool-io-cases.json not found above the working directory");
}
}
@@ -0,0 +1,119 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.support.StaticWebApplicationContext;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOSpec;
import stirling.software.common.model.tool.ToolIOWhen;
/**
* The registry reads real {@code @ToolIO} annotations off real handler mappings. Everything else
* checks the logic against a hand-built map, which would still pass if discovery silently found
* nothing.
*/
class ToolIODiscoveryTest {
@RestController
@RequestMapping("/api/v1/fixture")
static class FixtureController {
@PostMapping("/rotate")
@ToolIO(produces = ToolFormat.PDF)
public String rotate() {
return "";
}
@PostMapping("/split")
@ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO)
public String split() {
return "";
}
@PostMapping("/add-password")
@ToolIO(
produces = ToolFormat.PDF_ENCRYPTED,
cases =
@ToolIOCase(
when = {
@ToolIOWhen(param = "password", matches = ""),
@ToolIOWhen(param = "ownerPassword", matches = "")
},
produces = ToolFormat.PDF,
arity = ToolArity.SISO))
public String addPassword() {
return "";
}
@PostMapping("/undeclared")
public String undeclared() {
return "";
}
}
private static ToolIORegistry registry;
@BeforeAll
static void discover() {
StaticWebApplicationContext context = new StaticWebApplicationContext();
context.registerSingleton("fixtureController", FixtureController.class);
RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping();
mapping.setApplicationContext(context);
mapping.afterPropertiesSet();
context.getBeanFactory().registerSingleton("requestMappingHandlerMapping", mapping);
registry = new ToolIORegistry(context);
registry.discoverToolIO();
}
@Test
void readsDeclarationsOffHandlerMethods() {
ToolIOSpec rotate = registry.find("/api/v1/fixture/rotate").orElseThrow();
assertEquals(ToolFormat.PDF, rotate.produces());
assertEquals(ToolArity.SISO, rotate.arity());
assertTrue(rotate.acceptsFormat(ToolFormat.PDF));
assertFalse(rotate.acceptsFormat(ToolFormat.PDF_ENCRYPTED));
}
@Test
void skipsMethodsWithNoDeclaration() {
assertTrue(registry.find("/api/v1/fixture/undeclared").isEmpty());
}
@Test
void carriesArityThroughToTheUnpackDecision() {
assertTrue(registry.shouldUnpackZipResponse("/api/v1/fixture/split"));
assertFalse(registry.shouldUnpackZipResponse("/api/v1/fixture/rotate"));
}
@Test
void carriesCasesThroughToOutputResolution() {
ToolIOSpec spec = registry.find("/api/v1/fixture/add-password").orElseThrow();
assertEquals(
ToolFormat.PDF_ENCRYPTED,
spec.resolveOutput(Map.of("password", "x", "ownerPassword", "")).format());
assertEquals(
ToolFormat.PDF,
spec.resolveOutput(Map.of("password", "", "ownerPassword", "")).format());
}
@Test
void exposesInputExtensionsForRunTimeFileChecks() {
assertEquals(List.of("pdf"), registry.getExtensionTypes(false, "/api/v1/fixture/rotate"));
}
}
@@ -0,0 +1,87 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIOSpec;
/** The {@link ToolMetadataService} behaviour the pipeline executors depend on. */
class ToolIORegistryTest {
private static final String SPLIT = "/api/v1/general/split-pages";
private static final String MERGE = "/api/v1/general/merge-pdfs";
private static final String ROTATE = "/api/v1/general/rotate-pdf";
private static final String ATTACHMENTS = "/api/v1/security/get-attachments";
private static final String EXTRACT_IMAGES = "/api/v1/misc/extract-images";
private static final String CONVERT_ANY = "/api/v1/convert/file/pdf";
private static final String UNKNOWN = "/api/v1/general/does-not-exist";
private static ToolIOSpec spec(ToolFormat accepts, ToolFormat produces, ToolArity arity) {
return new ToolIOSpec(Set.of(accepts), produces, arity, List.of());
}
private final ToolIORegistry registry =
ToolIORegistry.forSpecs(
Map.of(
SPLIT, spec(ToolFormat.PDF, ToolFormat.PDF, ToolArity.SIMO),
MERGE, spec(ToolFormat.PDF, ToolFormat.PDF, ToolArity.MISO),
ROTATE, spec(ToolFormat.PDF, ToolFormat.PDF, ToolArity.SISO),
ATTACHMENTS, spec(ToolFormat.PDF, ToolFormat.ZIP, ToolArity.SISO),
EXTRACT_IMAGES, spec(ToolFormat.PDF, ToolFormat.IMAGE, ToolArity.SIMO),
CONVERT_ANY, spec(ToolFormat.ANY, ToolFormat.PDF, ToolArity.SISO)));
@Test
void multiInputFollowsArity() {
assertTrue(registry.isMultiInput(MERGE));
assertFalse(registry.isMultiInput(SPLIT));
assertFalse(registry.isMultiInput(ROTATE));
assertFalse(registry.isMultiInput(UNKNOWN));
}
@Test
void inputExtensionsComeFromAcceptedFormats() {
assertEquals(List.of("pdf"), registry.getExtensionTypes(false, ROTATE));
}
@Test
void outputExtensionsComeFromTheProducedFormat() {
assertEquals(List.of("pdf"), registry.getExtensionTypes(true, ROTATE));
assertTrue(registry.getExtensionTypes(true, EXTRACT_IMAGES).contains("png"));
}
@Test
void noRestrictionIsReportedAsNull() {
// Callers treat null as "any type accepted".
assertNull(registry.getExtensionTypes(false, CONVERT_ANY));
assertNull(registry.getExtensionTypes(false, UNKNOWN));
}
@Test
void multiOutputResponsesAreUnpacked() {
assertTrue(registry.shouldUnpackZipResponse(SPLIT));
assertTrue(registry.shouldUnpackZipResponse(EXTRACT_IMAGES));
}
@Test
void anArchiveDeliverableStaysPacked() {
// The archive is the deliverable; unpacking would lose it.
assertFalse(registry.shouldUnpackZipResponse(ATTACHMENTS));
}
@Test
void singleOutputResponsesAreNotUnpacked() {
assertFalse(registry.shouldUnpackZipResponse(ROTATE));
assertFalse(registry.shouldUnpackZipResponse(MERGE));
assertFalse(registry.shouldUnpackZipResponse(UNKNOWN));
}
}
@@ -325,11 +325,8 @@ class RegexPatternUtilsMoreTest {
}
@Test
void pageModeAndApiDocPatterns() {
void pageModePattern() {
assertTrue(utils.getPageModePattern().matcher("a/b").find());
assertTrue(utils.getApiDocOutputTypePattern().matcher("Output: PDF").find());
assertTrue(utils.getApiDocInputTypePattern().matcher("Input: PDF").find());
assertTrue(utils.getApiDocTypePattern().matcher("Type: WEB").find());
}
@Test
+2 -2
View File
@@ -50,7 +50,7 @@ dependencies {
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
implementation 'org.verapdf:validation-model:1.28.2'
implementation 'org.verapdf:validation-model:1.30.2'
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
implementation "org.mozilla:rhino:${rhinoVersion}"
@@ -61,7 +61,7 @@ dependencies {
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
@@ -40,7 +40,7 @@ public class AnalysisController {
@JsonDataResponse
@Operation(
summary = "Get PDF page count",
description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO")
description = "Returns total number of pages in PDF.")
public ResponseEntity<?> getPageCount(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
return ResponseEntity.ok(Map.of("pageCount", document.getNumberOfPages()));
@@ -54,7 +54,7 @@ public class AnalysisController {
@JsonDataResponse
@Operation(
summary = "Get basic PDF information",
description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO")
description = "Returns page count, version, file size.")
public ResponseEntity<?> getBasicInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> info = new HashMap<>();
@@ -72,7 +72,7 @@ public class AnalysisController {
@JsonDataResponse
@Operation(
summary = "Get PDF document properties",
description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO")
description = "Returns title, author, subject, etc.")
public ResponseEntity<?> getDocumentProperties(@ModelAttribute PDFFile file)
throws IOException {
// Load the document in read-only mode to prevent modifications and ensure the integrity of
@@ -105,7 +105,7 @@ public class AnalysisController {
@JsonDataResponse
@Operation(
summary = "Get page dimensions for all pages",
description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO")
description = "Returns width and height of each page.")
public ResponseEntity<?> getPageDimensions(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
List<Map<String, Float>> dimensions = new ArrayList<>();
@@ -128,8 +128,7 @@ public class AnalysisController {
@JsonDataResponse
@Operation(
summary = "Get form field information",
description =
"Returns count and details of form fields. Input:PDF Output:JSON Type:SISO")
description = "Returns count and details of form fields.")
public ResponseEntity<?> getFormFields(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> formInfo = new HashMap<>();
@@ -155,7 +154,7 @@ public class AnalysisController {
@JsonDataResponse
@Operation(
summary = "Get annotation information",
description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO")
description = "Returns count and types of annotations.")
public ResponseEntity<?> getAnnotationInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> annotInfo = new HashMap<>();
@@ -183,8 +182,7 @@ public class AnalysisController {
@JsonDataResponse
@Operation(
summary = "Get font information",
description =
"Returns list of fonts used in the document. Input:PDF Output:JSON Type:SISO")
description = "Returns list of fonts used in the document.")
public ResponseEntity<?> getFontInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> fontInfo = new HashMap<>();
@@ -212,8 +210,7 @@ public class AnalysisController {
@JsonDataResponse
@Operation(
summary = "Get security information",
description =
"Returns encryption and permission details. Input:PDF Output:JSON Type:SISO")
description = "Returns encryption and permission details.")
public ResponseEntity<?> getSecurityInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> securityInfo = new HashMap<>();
@@ -29,6 +29,8 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
@@ -47,12 +49,13 @@ public class BookletImpositionController {
value = "/booklet-imposition",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Create a booklet with proper page imposition",
description =
"This operation combines page reordering for booklet printing with multi-page layout. "
+ "It rearranges pages in the correct order for booklet printing and places multiple pages "
+ "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO")
"This operation combines page reordering for booklet printing with multi-page"
+ " layout. It rearranges pages in the correct order for booklet printing and"
+ " places multiple pages on each sheet for proper folding and binding.")
public ResponseEntity<Resource> createBookletImposition(
@ModelAttribute BookletImpositionRequest request) throws IOException {
@@ -27,6 +27,8 @@ import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -131,11 +133,12 @@ public class CropController {
value = "/crop",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Crops a PDF document",
description =
"This operation takes an input PDF file and crops it according to the given"
+ " coordinates. Input:PDF Output:PDF Type:SISO")
+ " coordinates.")
public ResponseEntity<Resource> cropPdf(@ModelAttribute CropPdfForm request)
throws IOException {
if (request.isAutoCrop()) {
@@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
@@ -47,6 +49,7 @@ public class EditTableOfContentsController {
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@ToolIO(produces = ToolFormat.JSON)
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@@ -151,6 +154,7 @@ public class EditTableOfContentsController {
value = "/edit-table-of-contents",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.")
@@ -34,6 +34,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.general.EditTextOperation;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -83,19 +85,18 @@ public class EditTextController {
value = "/edit-text",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@StandardPdfResponse
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Edit text in a PDF via find and replace",
description =
"Applies an ordered list of find/replace operations to the text in a PDF and"
+ " returns the edited PDF. Useful for find-and-replace, bulk renames"
+ " (e.g. updating a company name throughout a document), and copy"
+ " editing where the AI agent has identified specific replacements."
+ " Matching is performed against the joined text of each page, so"
+ " find strings can span multiple visual runs (titles split per word,"
+ " kerning-broken phrases). Cross-element matches are written as a"
+ " single replacement run anchored at the leftmost matched position;"
+ " centered or tracked text may shift left when its content changes."
+ " Input:PDF Output:PDF Type:SISO")
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
+ " updating a company name throughout a document), and copy editing where the AI"
+ " agent has identified specific replacements. Matching is performed against the"
+ " joined text of each page, so find strings can span multiple visual runs"
+ " (titles split per word, kerning-broken phrases). Cross-element matches are"
+ " written as a single replacement run anchored at the leftmost matched position;"
+ " centered or tracked text may shift left when its content changes.")
public ResponseEntity<Resource> editText(@ModelAttribute EditTextRequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -41,6 +41,9 @@ import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -265,12 +268,13 @@ public class MergeController {
value = "/merge-pdfs",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@StandardPdfResponse
@ToolIO(produces = ToolFormat.PDF, arity = ToolArity.MISO)
@Operation(
summary = "Merge multiple PDF files into one",
description =
"This endpoint merges multiple PDF files into a single PDF file. The merged"
+ " file will contain all pages from the input files in the order they were"
+ " provided. Input:PDF Output:PDF Type:MISO")
+ " provided.")
public ResponseEntity<Resource> mergePdfs(
@ModelAttribute MergePdfsRequest request,
@RequestParam(value = "fileOrder", required = false) String fileOrder)
@@ -25,6 +25,8 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralFormCopyUtils;
@@ -44,11 +46,12 @@ public class MultiPageLayoutController {
value = "/multi-page-layout",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Merge multiple pages of a PDF document into a single page",
description =
"This operation takes an input PDF file and the number of pages to merge into a"
+ " single sheet in the output PDF file. Input:PDF Output:PDF Type:SISO")
+ " single sheet in the output PDF file.")
public ResponseEntity<Resource> mergeMultiplePagesIntoOne(
@ModelAttribute MergeMultiplePagesRequest request) throws IOException {
@@ -27,6 +27,9 @@ import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -46,11 +49,12 @@ public class PdfOverlayController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@StandardPdfResponse
@ToolIO(produces = ToolFormat.PDF, arity = ToolArity.MISO)
@Operation(
summary = "Overlay PDF files in various modes",
description =
"Overlay PDF files onto a base PDF with different modes: Sequential,"
+ " Interleaved, or Fixed Repeat. Input:PDF Output:PDF Type:MIMO")
+ " Interleaved, or Fixed Repeat.")
public ResponseEntity<Resource> overlayPdfs(@ModelAttribute OverlayPdfsRequest request)
throws IOException {
MultipartFile baseFile = request.getFileInput();
@@ -29,6 +29,9 @@ import stirling.software.SPDF.model.api.general.PosterPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -49,13 +52,13 @@ public class PosterPdfController {
consumes = "multipart/form-data",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@MultiFileResponse
@ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO)
@Operation(
summary = "Split large PDF pages into smaller printable chunks",
description =
"This endpoint splits large or oddly-sized PDF pages into smaller chunks "
+ "suitable for printing on standard paper sizes (e.g., A4, Letter). "
+ "Divides each page into a grid of smaller pages using Apache PDFBox. "
+ "Input: PDF Output: ZIP-PDF Type: SISO")
"This endpoint splits large or oddly-sized PDF pages into smaller chunks"
+ " suitable for printing on standard paper sizes (e.g., A4, Letter). Divides each"
+ " page into a grid of smaller pages using Apache PDFBox.")
public ResponseEntity<Resource> posterPdf(@ModelAttribute PosterPdfRequest request)
throws Exception {
@@ -30,6 +30,8 @@ import stirling.software.SPDF.model.api.general.RearrangePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
@@ -50,12 +52,12 @@ public class RearrangePagesPDFController {
value = "/remove-pages",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@StandardPdfResponse
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Remove pages from a PDF file",
description =
"This endpoint removes specified pages from a given PDF file. Users can provide"
+ " a comma-separated list of page numbers or ranges to delete. Input:PDF"
+ " Output:PDF Type:SISO")
+ " a comma-separated list of page numbers or ranges to delete.")
public ResponseEntity<Resource> deletePages(@ModelAttribute PDFWithPageNums request)
throws IOException {
@@ -234,13 +236,13 @@ public class RearrangePagesPDFController {
value = "/rearrange-pages",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@StandardPdfResponse
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Rearrange pages in a PDF file",
description =
"This endpoint rearranges pages in a given PDF file based on the specified page"
+ " order or custom mode. Users can provide a page order as a"
+ " comma-separated list of page numbers or page ranges, or a custom mode."
+ " Input:PDF Output:PDF")
+ " order or custom mode. Users can provide a page order as a comma-separated list"
+ " of page numbers or page ranges, or a custom mode.")
public ResponseEntity<Resource> rearrangePages(@ModelAttribute RearrangePagesRequest request)
throws IOException {
MultipartFile pdfFile = request.getFileInput();
@@ -20,6 +20,8 @@ import stirling.software.SPDF.model.api.general.RotatePDFRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -38,11 +40,12 @@ public class RotationController {
value = "/rotate-pdf",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@StandardPdfResponse
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Rotate a PDF file",
description =
"This endpoint rotates a given PDF file by a specified angle. The angle must be"
+ " a multiple of 90. Input:PDF Output:PDF Type:SISO")
+ " a multiple of 90.")
public ResponseEntity<Resource> rotatePDF(@ModelAttribute RotatePDFRequest request)
throws IOException {
MultipartFile pdfFile = request.getFileInput();
@@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.general.ScalePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -87,11 +89,12 @@ public class ScalePagesController {
value = "/scale-pages",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Change the size of a PDF page/document",
description =
"This operation takes an input PDF file and the size to scale the pages to in"
+ " the output PDF file. Input:PDF Output:PDF Type:SISO")
+ " the output PDF file.")
public ResponseEntity<Resource> scalePages(@ModelAttribute ScalePagesRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
@@ -30,6 +30,9 @@ import stirling.software.SPDF.model.api.SplitPagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.GeneralUtils;
@@ -52,13 +55,13 @@ public class SplitPDFController {
value = "/split-pages",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@MultiFileResponse
@ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO)
@Operation(
summary = "Split a PDF file into separate documents",
description =
"This endpoint splits a given PDF file into separate documents based on the"
+ " specified page numbers or ranges. Users can specify pages using"
+ " individual numbers, ranges, or 'all' for every page. Input:PDF"
+ " Output:PDF Type:SIMO")
+ " specified page numbers or ranges. Users can specify pages using individual"
+ " numbers, ranges, or 'all' for every page.")
public ResponseEntity<Resource> splitPdf(@ModelAttribute SplitPagesRequest request)
throws IOException {
@@ -34,6 +34,9 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.PdfMetadata;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.ExceptionUtils;
@@ -93,11 +96,10 @@ public class SplitPdfByChaptersController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@MultiFileResponse
@ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO)
@Operation(
summary = "Split PDFs by Chapters",
description =
"Splits a PDF into chapters and returns a ZIP file. Input:PDF Output:ZIP-PDF"
+ " Type:SISO")
description = "Splits a PDF into chapters and returns a ZIP file.")
public ResponseEntity<Resource> splitPdf(@ModelAttribute SplitPdfByChaptersRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
@@ -34,6 +34,9 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -54,13 +57,13 @@ public class SplitPdfBySectionsController {
value = "/split-pdf-by-sections",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@MultiFileResponse
@ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO)
@Operation(
summary = "Split PDF pages into smaller sections",
description =
"Split each page of a PDF into smaller sections based on the user's choice"
+ " which page to split, and how to split"
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
+ " which page to split, and how to split ( halves, thirds, quarters, etc.), both"
+ " vertically and horizontally.")
public ResponseEntity<Resource> splitPdf(
@Valid @ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
MultipartFile file = request.getFileInput();
@@ -29,6 +29,9 @@ import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
@@ -52,14 +55,14 @@ public class SplitPdfBySizeController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@MultiFileResponse
@ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO)
@Operation(
summary = "Auto split PDF pages into separate documents based on size or count",
description =
"split PDF into multiple paged documents based on size/count, ie if 20 pages"
+ " and split into 5, it does 5 documents each 4 pages\r\n"
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
+ " (rounded so that it accepts 1.9MB but not 2.1MB) Input:PDF"
+ " Output:ZIP-PDF Type:SISO")
+ " and split into 5, it does 5 documents each 4 pages\r\n if 10MB and each page"
+ " is 1MB and you enter 2MB then 5 docs each 2MB (rounded so that it accepts"
+ " 1.9MB but not 2.1MB)")
public ResponseEntity<Resource> autoSplitPdf(
@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
@@ -22,6 +22,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
@@ -39,13 +41,13 @@ public class ToSinglePageController {
value = "/pdf-to-single-page",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@StandardPdfResponse
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Convert a multi-page PDF into a single long page PDF",
description =
"This endpoint converts a multi-page PDF document into a single paged PDF"
+ " document. The width of the single page will be same as the input's"
+ " width, but the height will be the sum of all the pages' heights."
+ " Input:PDF Output:PDF Type:SISO")
+ " document. The width of the single page will be same as the input's width, but"
+ " the height will be the sum of all the pages' heights.")
public ResponseEntity<Resource> pdfToSinglePage(@ModelAttribute PDFFile request)
throws IOException {
@@ -29,6 +29,8 @@ import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -61,11 +63,12 @@ public class ConvertEbookToPDFController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/ebook/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(accepts = ToolFormat.EBOOK, produces = ToolFormat.PDF)
@Operation(
summary = "Convert an eBook file to PDF",
description =
"This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)"
+ " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO")
+ " to PDF using Calibre.")
public ResponseEntity<Resource> convertEbookToPdf(
@ModelAttribute ConvertEbookToPdfRequest request) throws Exception {
if (!isCalibreEnabled()) {
@@ -27,6 +27,8 @@ import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.EmlToPdf;
@@ -49,13 +51,14 @@ public class ConvertEmlToPDF {
value = "/eml/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@StandardPdfResponse
@ToolIO(accepts = ToolFormat.EMAIL, produces = ToolFormat.PDF)
@Operation(
summary = "Convert EML/MSG to PDF",
description =
"This endpoint converts EML (email) and MSG (Outlook) files to PDF format"
+ " with extensive customization options. Features include font settings,"
+ " image constraints, display modes, attachment handling, and HTML debug"
+ " output. Input: EML or MSG file, Output: PDF or HTML file. Type: SISO")
"This endpoint converts EML (email) and MSG (Outlook) files to PDF format with"
+ " extensive customization options. Features include font settings, image"
+ " constraints, display modes, attachment handling, and HTML debug output. or MSG"
+ " file, or HTML file.")
public ResponseEntity<Resource> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput();
@@ -19,6 +19,8 @@ import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.*;
@@ -39,11 +41,14 @@ public class ConvertHtmlToPDF {
value = "/html/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@StandardPdfResponse
// A ZIP of HTML plus its CSS is a first-class input here, and is what convert/pdf/html emits.
@ToolIO(
accepts = {ToolFormat.HTML, ToolFormat.ZIP},
produces = ToolFormat.PDF)
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description =
"This endpoint takes an HTML or ZIP file input and converts it to a PDF format."
+ " Input:HTML Output:PDF Type:SISO")
"This endpoint takes an HTML or ZIP file input and converts it to a PDF format.")
public ResponseEntity<Resource> HtmlToPdf(@ModelAttribute HTMLToPdfRequest request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
@@ -41,6 +41,11 @@ import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOWhen;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CbrUtils;
import stirling.software.common.util.CbzUtils;
@@ -78,12 +83,20 @@ public class ConvertImgPDFController {
value = "/pdf/img",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@MultiFileResponse
@ToolIO(
produces = ToolFormat.IMAGE,
arity = ToolArity.SIMO,
cases =
@ToolIOCase(
when = @ToolIOWhen(param = "singleOrMultiple", matches = "single"),
produces = ToolFormat.IMAGE,
arity = ToolArity.SISO))
@Operation(
summary = "Convert PDF to image(s)",
description =
"This endpoint converts a PDF file to image(s) with the specified image format,"
+ " color type, and DPI. Users can choose to get a single image or multiple"
+ " images. Input:PDF Output:Image Type:SI-Conditional")
+ " images.")
public ResponseEntity<?> convertToImage(@ModelAttribute ConvertToImageRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
@@ -248,12 +261,13 @@ public class ConvertImgPDFController {
value = "/img/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@StandardPdfResponse
@ToolIO(accepts = ToolFormat.IMAGE, produces = ToolFormat.PDF, arity = ToolArity.MISO)
@Operation(
summary = "Convert images to a PDF file",
description =
"This endpoint converts one or more images to a PDF file. Users can specify"
+ " whether to stretch the images to fit the PDF page, and whether to"
+ " automatically rotate the images. Input:Image Output:PDF Type:MISO")
+ " automatically rotate the images.")
public ResponseEntity<byte[]> convertToPdf(@ModelAttribute ConvertToPdfRequest request)
throws IOException {
MultipartFile[] file = request.getFileInput();
@@ -279,11 +293,10 @@ public class ConvertImgPDFController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/cbz/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@ToolIO(accepts = ToolFormat.CBZ, produces = ToolFormat.PDF)
@Operation(
summary = "Convert CBZ comic book archive to PDF",
description =
"This endpoint converts a CBZ (ZIP) comic book archive to a PDF file. "
+ "Input:CBZ Output:PDF Type:SISO")
description = "This endpoint converts a CBZ (ZIP) comic book archive to a PDF file.")
public ResponseEntity<Resource> convertCbzToPdf(@ModelAttribute ConvertCbzToPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
@@ -308,11 +321,10 @@ public class ConvertImgPDFController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/cbz",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.CBZ)
@Operation(
summary = "Convert PDF to CBZ comic book archive",
description =
"This endpoint converts a PDF file to a CBZ (ZIP) comic book archive. "
+ "Input:PDF Output:CBZ Type:SISO")
description = "This endpoint converts a PDF file to a CBZ (ZIP) comic book archive.")
public ResponseEntity<Resource> convertPdfToCbz(@ModelAttribute ConvertPdfToCbzRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
@@ -334,11 +346,10 @@ public class ConvertImgPDFController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/cbr/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@ToolIO(accepts = ToolFormat.CBR, produces = ToolFormat.PDF)
@Operation(
summary = "Convert CBR comic book archive to PDF",
description =
"This endpoint converts a CBR (RAR) comic book archive to a PDF file. "
+ "Input:CBR Output:PDF Type:SISO")
description = "This endpoint converts a CBR (RAR) comic book archive to a PDF file.")
public ResponseEntity<?> convertCbrToPdf(@ModelAttribute ConvertCbrToPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
@@ -363,11 +374,12 @@ public class ConvertImgPDFController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/cbr",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.CBR)
@Operation(
summary = "Convert PDF to CBR comic book archive",
description =
"This endpoint converts a PDF file to a CBR comic book archive using the local RAR CLI. "
+ "Input:PDF Output:CBR Type:SISO")
"This endpoint converts a PDF file to a CBR comic book archive using the local"
+ " RAR CLI.")
public ResponseEntity<?> convertPdfToCbr(@ModelAttribute ConvertPdfToCbrRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
@@ -28,6 +28,8 @@ import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.*;
@@ -47,11 +49,15 @@ public class ConvertMarkdownToPdf {
value = "/markdown/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@StandardPdfResponse
// A ZIP of Markdown plus its images is a first-class input here, not just a bare .md file.
@ToolIO(
accepts = {ToolFormat.MARKDOWN, ToolFormat.ZIP},
produces = ToolFormat.PDF)
@Operation(
summary = "Convert a Markdown file to PDF",
description =
"This endpoint takes a Markdown file or ZIP (containing Markdown + images) input, converts it to HTML, and then to"
+ " PDF format. Input:MARKDOWN Output:PDF Type:SISO")
"This endpoint takes a Markdown file or ZIP (containing Markdown + images)"
+ " input, converts it to HTML, and then to PDF format.")
public ResponseEntity<Resource> markdownToPdf(@ModelAttribute GeneralFile generalFile)
throws Exception {
MultipartFile fileInput = generalFile.getFileInput();
@@ -31,6 +31,8 @@ import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.ExceptionUtils;
@@ -209,11 +211,10 @@ public class ConvertOfficeController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/file/pdf",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(accepts = ToolFormat.ANY, produces = ToolFormat.PDF)
@Operation(
summary = "Convert a file to a PDF using LibreOffice",
description =
"This endpoint converts a given file to a PDF using LibreOffice API Input:ANY"
+ " Output:PDF Type:SISO")
description = "This endpoint converts a given file to a PDF using LibreOffice API")
public ResponseEntity<Resource> processFileToPDF(@ModelAttribute GeneralFile generalFile)
throws Exception {
MultipartFile inputFile = generalFile.getFileInput();
@@ -28,6 +28,8 @@ import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.Targe
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -86,11 +88,10 @@ public class ConvertPDFToEpubController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/epub",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.EBOOK)
@Operation(
summary = "Convert PDF to EPUB/AZW3",
description =
"Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre. Input:PDF"
+ " Output:EPUB/AZW3 Type:SISO")
description = "Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre.")
public ResponseEntity<Resource> convertPdfToEpub(
@ModelAttribute ConvertPdfToEpubRequest request) throws Exception {
@@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -50,11 +52,12 @@ public class ConvertPDFToExcelController {
value = "/pdf/xlsx",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.EXCEL)
@Operation(
summary = "Convert a PDF to an Excel spreadsheet (XLSX)",
description =
"Extracts tabular data from each page of a PDF and writes it into an Excel"
+ " workbook, one sheet per table. Input:PDF Output:XLSX Type:SISO")
+ " workbook, one sheet per table.")
public ResponseEntity<Resource> pdfToExcel(@ModelAttribute PDFWithPageNums request)
throws Exception {
String baseName =
@@ -15,6 +15,8 @@ import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
@@ -29,10 +31,10 @@ public class ConvertPDFToHtml {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/html",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@ToolIO(produces = ToolFormat.ZIP)
@Operation(
summary = "Convert PDF to HTML",
description =
"This endpoint converts a PDF file to HTML format. Input:PDF Output:HTML Type:SISO")
description = "This endpoint converts a PDF file to HTML format.")
public ResponseEntity<Resource> processPdfToHTML(@ModelAttribute PDFFile file)
throws Exception {
MultipartFile inputFile = file.getFileInput();
@@ -24,6 +24,11 @@ import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOWhen;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PDFToFile;
@@ -43,11 +48,10 @@ public class ConvertPDFToOffice {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/presentation",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.PPT)
@Operation(
summary = "Convert PDF to Presentation format",
description =
"This endpoint converts a given PDF file to a Presentation format. Input:PDF"
+ " Output:PPT Type:SISO")
description = "This endpoint converts a given PDF file to a Presentation format.")
public ResponseEntity<Resource> processPdfToPresentation(
@ModelAttribute PdfToPresentationRequest request)
throws IOException, InterruptedException {
@@ -61,11 +65,16 @@ public class ConvertPDFToOffice {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/text",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@ToolIO(
produces = ToolFormat.TEXT,
cases =
@ToolIOCase(
when = @ToolIOWhen(param = "outputFormat", matches = "rtf"),
produces = ToolFormat.WORD,
arity = ToolArity.SISO))
@Operation(
summary = "Convert PDF to Text or RTF format",
description =
"This endpoint converts a given PDF file to Text or RTF format. Input:PDF"
+ " Output:TXT Type:SISO")
description = "This endpoint converts a given PDF file to Text or RTF format.")
public ResponseEntity<Resource> processPdfToRTForTXT(
@ModelAttribute PdfToTextOrRTFRequest request)
throws IOException, InterruptedException {
@@ -94,11 +103,10 @@ public class ConvertPDFToOffice {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/word",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.WORD)
@Operation(
summary = "Convert PDF to Word document",
description =
"This endpoint converts a given PDF file to a Word document format. Input:PDF"
+ " Output:WORD Type:SISO")
description = "This endpoint converts a given PDF file to a Word document format.")
public ResponseEntity<Resource> processPdfToWord(@ModelAttribute PdfToWordRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
@@ -111,11 +119,10 @@ public class ConvertPDFToOffice {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/xml",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.XML)
@Operation(
summary = "Convert PDF to XML",
description =
"This endpoint converts a PDF file to an XML file. Input:PDF Output:XML"
+ " Type:SISO")
description = "This endpoint converts a PDF file to an XML file.")
public ResponseEntity<Resource> processPdfToXML(@ModelAttribute PDFFile file) throws Exception {
MultipartFile inputFile = file.getFileInput();
@@ -91,6 +91,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -577,10 +579,13 @@ public class ConvertPDFToPDFA {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/pdfa",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Convert a PDF to a PDF/A or PDF/X",
description =
"This endpoint converts a PDF file to a PDF/A or PDF/X file using Ghostscript (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format designed for long-term archiving, while PDF/X is optimized for print production. Input:PDF Output:PDF Type:SISO")
"This endpoint converts a PDF file to a PDF/A or PDF/X file using Ghostscript"
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format designed for"
+ " long-term archiving, while PDF/X is optimized for print production.")
public ResponseEntity<Resource> pdfToPdfA(@ModelAttribute PdfToPdfARequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -62,7 +62,7 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Convert PDF to Text Editor Format",
description =
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool. Input:PDF Output:JSON Type:SISO")
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool.")
public ResponseEntity<Resource> convertPdfToJson(
@ModelAttribute PDFFile request,
@RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight)
@@ -104,7 +104,7 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Convert Text Editor Format to PDF",
description =
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool. Input:JSON Output:PDF Type:SISO")
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool.")
public ResponseEntity<Resource> convertJsonToPdf(@ModelAttribute GeneralFile request)
throws Exception {
MultipartFile jsonFile = request.getFileInput();
@@ -139,7 +139,7 @@ public class ConvertPdfJsonController {
description =
"Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for"
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
+ " authenticated user. Input:PDF Output:JSON Type:SISO")
+ " authenticated user.")
public ResponseEntity<Resource> extractPdfMetadata(@ModelAttribute PDFFile request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -226,7 +226,7 @@ public class ConvertPdfJsonController {
description =
"Retrieves a single page's content from a previously cached PDF document for the text editor tool."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
+ " authenticated user.")
public ResponseEntity<Resource> extractSinglePage(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
@@ -255,7 +255,7 @@ public class ConvertPdfJsonController {
description =
"Retrieves the font payloads used by a single page from a previously cached PDF document."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
+ " authenticated user.")
public ResponseEntity<Resource> extractPageFonts(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
@@ -56,8 +56,7 @@ public class ConvertPdfToVideoController {
@Operation(
summary = "Convert PDF to Video Slideshow",
description =
"This endpoint converts a PDF document into a slideshow-style video."
+ " Input:PDF Output:Video Type:SISO")
"This endpoint converts a PDF document into a slideshow-style video.")
public ResponseEntity<byte[]> convertPdfToVideo(@ModelAttribute PdfToVideoRequest request)
throws Exception {
if (!CheckProgramInstall.isFfmpegAvailable()) {
@@ -29,6 +29,11 @@ import stirling.software.SPDF.utils.SvgToPdf;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOWhen;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.SvgSanitizer;
@@ -50,15 +55,23 @@ public class ConvertSvgToPDF {
value = "/svg/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@MultiFileResponse
@ToolIO(
accepts = ToolFormat.IMAGE,
produces = ToolFormat.PDF,
arity = ToolArity.MIMO,
cases =
@ToolIOCase(
when = @ToolIOWhen(param = "combineIntoSinglePdf", matches = "true"),
produces = ToolFormat.PDF,
arity = ToolArity.MISO))
@Operation(
summary = "Convert SVG to PDF",
description =
"This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF format. "
+ "Each SVG is converted to a separate PDF file. "
+ "The conversion preserves vector graphics for crisp output at any resolution - no rasterization occurs. "
+ "SVG dimensions (width/height) determine the PDF page size; defaults to A4 if not specified. "
+ "SVG content is sanitized to prevent XSS attacks. "
+ "Input: SVG file(s), Output: PDF file(s) or ZIP. Type: MIMO")
"This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF"
+ " format. Each SVG is converted to a separate PDF file. The conversion preserves"
+ " vector graphics for crisp output at any resolution - no rasterization occurs."
+ " SVG dimensions (width/height) determine the PDF page size; defaults to A4 if"
+ " not specified. SVG content is sanitized to prevent XSS attacks.")
public ResponseEntity<Resource> convertSvgToPdf(@ModelAttribute SvgToPdfRequest request) {
MultipartFile[] inputFiles = request.getFileInput();

Some files were not shown because too many files have changed in this diff Show More