diff --git a/.cdsprettier.json b/.cdsprettier.json new file mode 100644 index 0000000..d2d4809 --- /dev/null +++ b/.cdsprettier.json @@ -0,0 +1,8 @@ +{ + "maxDocCommentLine": 80, + "formatDocComments": true, + "tabSize": 2, + "alignPostAnnotations": false, + "alignColonsInAnnotations": false, + "alignValuesInAnnotations": false +} \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9505331 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# Each pull request will require review and approval from the code owners +# before it can be merged. +# +# Learn more: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# Global ownership — cdsmunich team owns everything +* @cap-java/cdsmunich diff --git a/.github/actions/cf-bind/action.yml b/.github/actions/cf-bind/action.yml new file mode 100644 index 0000000..06c12c8 --- /dev/null +++ b/.github/actions/cf-bind/action.yml @@ -0,0 +1,56 @@ +name: Bind Cloud Foundry Services +description: Login to CF and bind the AI Core service for hybrid testing via cds bind. + +inputs: + cf-api: + description: Cloud Foundry API endpoint + required: true + cf-username: + description: Cloud Foundry username + required: true + cf-password: + description: Cloud Foundry password + required: true + cf-org: + description: Cloud Foundry organization + required: true + cf-space: + description: Cloud Foundry space + required: true + +runs: + using: composite + steps: + - name: CF Login + uses: cap-java/.github/actions/cf-login@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + with: + cf-api: ${{ inputs.cf-api }} + cf-username: ${{ inputs.cf-username }} + cf-password: ${{ inputs.cf-password }} + cf-org: ${{ inputs.cf-org }} + cf-space: ${{ inputs.cf-space }} + + - name: Install @sap/cds-dk + shell: bash + run: | + npm i -g @sap/cds-dk@9.9.1 + echo "$(npm config get prefix)/bin" >> "${GITHUB_PATH}" + + - name: Install CDS dependencies + shell: bash + run: npm ci || npm install + working-directory: integration-tests + + - name: Bind ai-core + shell: bash + working-directory: integration-tests + run: | + for i in {1..5}; do + cds bind ai-core -2 ai-core:ai-core-key && break + if [ "$i" -eq 5 ]; then + echo "cds bind ai-core failed after 5 attempts." + exit 1 + fi + echo "cds bind ai-core failed, retrying ($i/5)..." + sleep 30 + done diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml new file mode 100644 index 0000000..97f6520 --- /dev/null +++ b/.github/actions/integration-tests/action.yml @@ -0,0 +1,44 @@ +name: Integration Tests +description: Run integration tests using Maven with cds bind for service bindings. + +inputs: + java-version: + description: The Java version the build shall run with. + required: true + maven-version: + description: The Maven version the build shall run with. + required: true + +runs: + using: composite + steps: + - name: Set up Java ${{ inputs.java-version }} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: ${{ inputs.java-version }} + distribution: sapmachine + cache: maven + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Build dependencies for integration tests + run: mvn clean install -ntp -B -pl cds-feature-ai-core,cds-feature-recommendations,cds-feature-sap-document-ai,cds-starter-ai -am -DskipTests + shell: bash + + - name: Integration Tests (spring) + env: + CDS_AICORE_TEST_RESOURCE_GROUP: itest-${{ github.run_id }}-${{ github.run_attempt }}-j${{ inputs.java-version }} + run: cds bind --exec -- mvn clean verify -ntp -B -f pom.xml + working-directory: integration-tests + shell: bash + + - name: Cleanup AI Core test resource groups + if: always() + working-directory: integration-tests + shell: bash + env: + RESOURCE_GROUP_PREFIX: itest-${{ github.run_id }}-${{ github.run_attempt }}-j${{ inputs.java-version }} + run: cds bind --exec -- node ${{ github.workspace }}/.github/scripts/cleanup-resource-groups.js diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml new file mode 100644 index 0000000..9201134 --- /dev/null +++ b/.github/actions/scan-with-sonar/action.yml @@ -0,0 +1,91 @@ +name: Scan with SonarQube +description: Scans the project with SonarQube. Caller is responsible for setting up the CF binding (e.g. via the cf-bind action) before invoking this action. + +inputs: + sonarq-token: + description: The token to use for SonarQube authentication + required: true + github-token: + description: The token to use for GitHub authentication + required: true + java-version: + description: The version of Java to use + required: true + maven-version: + description: The version of Maven to use + required: true + +runs: + using: composite + + steps: + - name: Set up Java ${{inputs.java-version}} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: ${{inputs.java-version}} + distribution: sapmachine + cache: maven + + - name: Set up Maven ${{inputs.maven-version}} + uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 + with: + maven-version: ${{inputs.maven-version}} + + - name: Get Revision + id: get-revision + run: | + echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Build and test main modules + run: mvn clean install -ntp -B -pl cds-feature-ai-core,cds-feature-recommendations,cds-starter-ai -am + shell: bash + + - name: Run integration tests + env: + CDS_AICORE_TEST_RESOURCE_GROUP: sonar-${{ github.run_id }}-${{ github.run_attempt }} + run: cds bind --exec -- mvn clean verify install -ntp -B + working-directory: integration-tests + shell: bash + + - name: Cleanup AI Core test resource groups + if: always() + working-directory: integration-tests + shell: bash + env: + RESOURCE_GROUP_PREFIX: sonar-${{ github.run_id }}-${{ github.run_attempt }} + run: cds bind --exec -- node ${{ github.workspace }}/.github/scripts/cleanup-resource-groups.js + + - name: Generate aggregate coverage report + run: mvn verify -ntp -B -pl coverage-report -am -DskipTests + shell: bash + + - name: Verify JaCoCo reports exist + run: | + echo "=== Checking JaCoCo reports ===" + find . -name "jacoco.xml" -type f + if [ -f "coverage-report/target/site/jacoco-aggregate/jacoco.xml" ]; then + echo "Found: coverage-report/target/site/jacoco-aggregate/jacoco.xml" + else + echo "Missing: coverage-report/target/site/jacoco-aggregate/jacoco.xml" + exit 1 + fi + shell: bash + + - name: SonarQube Scan + run: > + mvn org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + -Dsonar.host.url=https://sonar.tools.sap + -Dsonar.token="${SONAR_TOKEN}" + -Dsonar.projectKey=com.sap.cds.cds-ai + -Dsonar.projectVersion=${{ steps.get-revision.outputs.REVISION }} + -Dsonar.qualitygate.wait=true + -Dsonar.java.source=17 + -Dsonar.exclusions=**/samples/**,**/integration-tests/** + -Dsonar.coverage.jacoco.xmlReportPaths=${{ github.workspace }}/cds-feature-ai-core/target/site/jacoco/jacoco.xml,${{ github.workspace }}/cds-feature-recommendations/target/site/jacoco/jacoco.xml,${{ github.workspace }}/integration-tests/spring/target/site/jacoco/jacoco.xml,${{ github.workspace }}/coverage-report/target/site/jacoco-aggregate/jacoco.xml + -Dsonar.coverage.exclusions=**/src/test/**,**/src/gen/** + -B -ntp + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + SONAR_TOKEN: ${{ inputs.sonarq-token }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8be0b3d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + - package-ecosystem: maven + directories: + - "/" + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + minor-patch: + patterns: + - "*" + update-types: + - minor + - patch diff --git a/.github/scripts/cleanup-resource-groups.js b/.github/scripts/cleanup-resource-groups.js new file mode 100644 index 0000000..257513c --- /dev/null +++ b/.github/scripts/cleanup-resource-groups.js @@ -0,0 +1,115 @@ +/** + * Cleans up AI Core test resource groups. + * + * Required environment variables: + * RESOURCE_GROUP_PREFIX - The prefix identifying resource groups owned by this run + * (e.g. "itest-12345-1-j17" or "sonar-12345-1") + * + * Optional environment variables: + * STALE_PREFIXES - Comma-separated list of additional prefixes to clean up + * (defaults to "itest-rg-,cds-itest-") + * + * Credentials are resolved from VCAP_SERVICES (via cds bind) or AICORE_SERVICE_KEY. + */ +const https = require("https"); + +const DEFAULT_STALE_PREFIXES = ["itest-rg-", "cds-itest-"]; + +function getCredentials() { + const vcap = JSON.parse(process.env.VCAP_SERVICES || "{}"); + return ( + (vcap.aicore || vcap["ai-core"] || [{}])[0].credentials || + JSON.parse(process.env.AICORE_SERVICE_KEY || "null") + ); +} + +function request(url, opts = {}) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = https.request( + { + hostname: u.hostname, + path: u.pathname + u.search, + method: opts.method || "GET", + headers: opts.headers || {}, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve({ status: res.statusCode, body: data })); + } + ); + req.on("error", reject); + if (opts.body) req.write(opts.body); + req.end(); + }); +} + +async function getAccessToken(credentials) { + const tokenUrl = credentials.url + "/oauth/token"; + const params = new URLSearchParams({ grant_type: "client_credentials" }); + const authHeader = + "Basic " + + Buffer.from(credentials.clientid + ":" + credentials.clientsecret).toString( + "base64" + ); + const res = await request(tokenUrl + "?" + params.toString(), { + headers: { Authorization: authHeader }, + }); + return JSON.parse(res.body).access_token; +} + +async function deleteResourceGroups(apiUrl, headers, prefixes) { + const res = await request(apiUrl + "/v2/admin/resourceGroups", { headers }); + const groups = JSON.parse(res.body).resources || []; + const toDelete = groups.filter( + (rg) => + rg.resourceGroupId && + prefixes.some((p) => rg.resourceGroupId.startsWith(p)) + ); + + for (const rg of toDelete) { + const delRes = await request( + apiUrl + "/v2/admin/resourceGroups/" + rg.resourceGroupId, + { method: "DELETE", headers } + ); + console.log("Delete", rg.resourceGroupId, "->", delRes.status); + } + + console.log("Cleaned up", toDelete.length, "resource groups"); +} + +async function main() { + const ownPrefix = process.env.RESOURCE_GROUP_PREFIX; + if (!ownPrefix) { + console.error("RESOURCE_GROUP_PREFIX environment variable is required"); + process.exit(1); + } + + const credentials = getCredentials(); + if (!credentials) { + console.log("No AI Core credentials found, skipping cleanup"); + return; + } + + const stalePrefixes = process.env.STALE_PREFIXES + ? process.env.STALE_PREFIXES.split(",").map((s) => s.trim()) + : DEFAULT_STALE_PREFIXES; + + const prefixes = [ownPrefix, ...stalePrefixes]; + + const apiUrl = credentials.serviceurls.AI_API_URL; + const token = await getAccessToken(credentials); + const headers = { + Authorization: "Bearer " + token, + "AI-Resource-Group": "default", + }; + + console.log("Cleaning resource groups matching prefixes:", prefixes); + await deleteResourceGroups(apiUrl, headers, prefixes); +} + +main().catch((e) => { + console.error(e.message); + process.exit(0); +}); diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml new file mode 100644 index 0000000..5d37720 --- /dev/null +++ b/.github/workflows/issue.yml @@ -0,0 +1,14 @@ +name: Label issues + +permissions: {} + +on: + issues: + types: + - opened + +jobs: + label_issues: + uses: cap-java/.github/.github/workflows/issue.yml@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + permissions: + issues: write diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..7e9ee95 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,39 @@ +name: CI - MAIN + +env: + MAVEN_VERSION: '3.9.15' + +on: + workflow_dispatch: + push: + branches: [main] + +jobs: + blackduck: + name: Blackduck Scan + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Scan With Black Duck + uses: cap-java/.github/actions/scan-with-blackduck@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + with: + blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + maven-version: ${{ env.MAVEN_VERSION }} + project-name: com.sap.cds.cds-ai + included-modules: cds-feature-ai-core,cds-feature-recommendations,cds-starter-ai + scan_mode: FULL + + build-and-test: + uses: ./.github/workflows/pipeline.yml + permissions: + contents: read + security-events: write + actions: read + packages: read + secrets: inherit diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..2671fb0 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,150 @@ +name: Reusable Workflow + +env: + MAVEN_VERSION: '3.9.15' + +on: + workflow_call: + +jobs: + tests: + name: Tests (Java ${{ matrix.java-version }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + java-version: [17, 21] + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: ${{ matrix.java-version }} + distribution: sapmachine + cache: maven + + - name: Set up Maven + uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 + with: + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Run Tests + run: mvn test -ntp -B -P '!with-integration-tests' + + integration-tests: + name: Integration Tests (Java ${{ matrix.java-version }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + java-version: [17, 21] + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Bind CF Services + uses: ./.github/actions/cf-bind + with: + cf-api: ${{ secrets.CF_API_AWS }} + cf-username: ${{ secrets.CF_USERNAME }} + cf-password: ${{ secrets.CF_PASSWORD }} + cf-org: ${{ secrets.CF_ORG_AWS }} + cf-space: ${{ secrets.CF_SPACE_AWS }} + + - name: Integration Tests + uses: ./.github/actions/integration-tests + with: + java-version: ${{ matrix.java-version }} + maven-version: ${{ env.MAVEN_VERSION }} + + local-mtx-tests: + name: Local MTX Tests (Java ${{ matrix.java-version }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + java-version: [17, 21] + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: ${{ matrix.java-version }} + distribution: sapmachine + cache: maven + + - name: Set up Maven + uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 + with: + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Run Local MTX Tests + run: mvn clean verify -ntp -B -pl integration-tests/mtx-local/srv -am -P mtx-integration-tests + + sonarqube-scan: + name: SonarQube Scan + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + + - name: Bind CF Services + uses: ./.github/actions/cf-bind + with: + cf-api: ${{ secrets.CF_API_AWS }} + cf-username: ${{ secrets.CF_USERNAME }} + cf-password: ${{ secrets.CF_PASSWORD }} + cf-org: ${{ secrets.CF_ORG_AWS }} + cf-space: ${{ secrets.CF_SPACE_AWS }} + + - name: SonarQube Scan + continue-on-error: true + uses: ./.github/actions/scan-with-sonar + with: + java-version: '17' + maven-version: ${{ env.MAVEN_VERSION }} + sonarq-token: ${{ secrets.SONARQ_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + codeql: + name: CodeQL Analysis (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + security-events: write + packages: read + actions: read + contents: read + strategy: + fail-fast: false + matrix: + language: [java-kotlin, actions] + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: CodeQL Analysis + uses: cap-java/.github/actions/scan-with-codeql@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + with: + java-version: '17' + maven-version: ${{ env.MAVEN_VERSION }} + language: ${{ matrix.language }} + queries: security-extended diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..198ed92 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,18 @@ +name: CI - PR + +permissions: + actions: read + contents: read + packages: read + security-events: write + +on: + workflow_dispatch: + pull_request: + branches: [main] + types: [reopened, synchronize, opened] + +jobs: + build-and-test: + uses: ./.github/workflows/pipeline.yml + secrets: inherit diff --git a/.github/workflows/prevent-issue-labeling.yml b/.github/workflows/prevent-issue-labeling.yml new file mode 100644 index 0000000..4c99391 --- /dev/null +++ b/.github/workflows/prevent-issue-labeling.yml @@ -0,0 +1,13 @@ +name: Prevent "New" Label on Issues + +permissions: {} + +on: + issues: + types: [labeled] + +jobs: + remove_new_label: + uses: cap-java/.github/.github/workflows/prevent-issue-labeling.yml@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + permissions: + issues: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..060263d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +name: Deploy to Maven Central + +env: + JAVA_VERSION: '17' + MAVEN_VERSION: '3.9.15' + +on: + release: + types: ["released"] + +jobs: + requires-approval: + runs-on: ubuntu-latest + name: "Waiting for release approval" + environment: release-approval + permissions: + contents: read + steps: + - name: Approval Step + run: echo "Release has been approved!" + + verify-version: + needs: requires-approval + name: Verify Version Matches Tag + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: sapmachine + cache: maven + + - name: Set up Maven + uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 + with: + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Verify pom.xml revision matches release tag + env: + TAG: ${{ github.event.release.tag_name }} + run: | + REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout) + echo "Tag: $TAG" + echo "Revision: $REVISION" + if [ "$TAG" != "$REVISION" ]; then + echo "::error::Release tag '$TAG' does not match pom.xml '$REVISION'. Open a 'Prep release' PR to bump the version before tagging." + exit 1 + fi + shell: bash + + blackduck: + needs: verify-version + name: Blackduck Scan + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Scan With Black Duck + uses: cap-java/.github/actions/scan-with-blackduck@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + with: + blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + maven-version: ${{ env.MAVEN_VERSION }} + project-name: com.sap.cds.cds-ai + included-modules: cds-feature-ai-core,cds-feature-recommendations,cds-starter-ai + version: ${{ github.event.release.tag_name }} + + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: verify-version + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Build + uses: cap-java/.github/actions/build@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + with: + java-version: ${{ env.JAVA_VERSION }} + maven-version: ${{ env.MAVEN_VERSION }} + maven-args: "-P '!with-integration-tests'" + + deploy: + name: Deploy to Maven Central + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [blackduck, build] + environment: release + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Deploy + uses: cap-java/.github/actions/deploy-release@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + with: + user: ${{ secrets.CENTRAL_REPOSITORY_USER }} + password: ${{ secrets.CENTRAL_REPOSITORY_PASS }} + gpg-pub-key: ${{ secrets.PGP_PUBKEY_ID }} + gpg-private-key: ${{ secrets.PGP_PRIVATE_KEY }} + gpg-passphrase: ${{ secrets.PGP_PASSPHRASE }} + revision: ${{ github.event.release.tag_name }} + maven-version: ${{ env.MAVEN_VERSION }} + maven-profiles: "deploy-release,'!with-integration-tests'" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..8bbf3c1 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,15 @@ +name: "Close stale issues" + +permissions: {} + +on: + schedule: + - cron: "30 1 * * *" + +jobs: + stale: + uses: cap-java/.github/.github/workflows/stale.yml@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main + permissions: + actions: write + issues: write + pull-requests: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6abc628 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +**/gen/ +**/edmx/ +**/src/test/resources/model/csn.json +*.db +*.sqlite +*.sqlite-wal +*.sqlite-shm +schema*.sql +default-env.json + +**/bin/ +**/target/ +.flattened-pom.xml +.classpath +.project +.settings + +**/node/ +**/node_modules/ +**/.mta/ +*.mtar + +*.log* +gc_history* +hs_err* +*.tgz +*.iml + +.vscode +.idea +.reloadtrigger + +**/.DS_Store + +.cdsrc-private.json diff --git a/.hyperspace/pull_request_bot.json b/.hyperspace/pull_request_bot.json new file mode 100644 index 0000000..af7508b --- /dev/null +++ b/.hyperspace/pull_request_bot.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://devops-insights-pr-bot.cfapps.eu10-004.hana.ondemand.com/schema/pull_request_bot.json", + "features": { + "control_panel": false, + "summarize": { + "auto_generate_summary": true, + "auto_insert_summary": true, + "auto_run_on_draft_pr": true, + "use_custom_summarize_prompt": false, + "use_custom_summarize_output_template": false, + "excluded_paths": [], + "auto_exclude_authors": [] + }, + "review": { + "auto_generate_review": true, + "auto_run_on_draft_pr": false, + "use_custom_review_focus": false, + "excluded_paths": [], + "auto_exclude_authors": [] + }, + "sonar_fix": { + "enable": true, + "excluded_rules": [] + }, + "pipeline_fix": { + "enable": true + } + }, + "excluded_paths": [] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b08a07c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Change Log + +- All notable changes to this project are documented in this file. +- The format is based on [Keep a Changelog](https://keepachangelog.com/). +- This project adheres to [Semantic Versioning](https://semver.org/). + +## Version 1.0.0 - TBD + +### Added + +### Changed + +### Fixed + +### Removed \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8278ed7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing + +## Code of Conduct + +All members of the project community must abide by the [SAP Open Source Code of Conduct](https://github.com/SAP/.github/blob/main/CODE_OF_CONDUCT.md). +Only by respecting each other we can develop a productive, collaborative community. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by [opening an issue](https://github.com/cap-java/cds-ai/issues) or by contacting one of the project maintainers listed in the repository. + +## Engaging in Our Project + +We use GitHub to manage reviews of pull requests. + +- If you are a new contributor, see: [Steps to Contribute](#steps-to-contribute) + +- Before implementing your change, create an issue that describes the problem you would like to solve or the code that should be enhanced. Please note that you are willing to work on that issue. + +- The team will review the issue and decide whether it should be implemented as a pull request. In that case, they will assign the issue to you. If the team decides against picking up the issue, the team will post a comment with an explanation. + +## Steps to Contribute + +Should you wish to work on an issue, please claim it first by commenting on the GitHub issue that you want to work on. This is to prevent duplicated efforts from other contributors on the same issue. + +If you have questions about one of the issues, please comment on them, and one of the maintainers will clarify. + +## Contributing Code or Documentation + +You are welcome to contribute code in order to fix a bug or to implement a new feature that is logged as an issue. + +The following rule governs code contributions: + +- Contributions must be licensed under the [Apache 2.0 License](./LICENSE) +- Due to legal reasons, contributors will be asked to accept a Developer Certificate of Origin (DCO) when they create the first pull request to this project. This happens in an automated fashion during the submission process. SAP uses [the standard DCO text of the Linux Foundation](https://developercertificate.org/). + +## Issues and Planning + +- We use GitHub issues to track bugs and enhancement requests. + +- Please provide as much context as possible when you open an issue. The information you provide must be comprehensive enough to reproduce that issue for the assignee. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c026c86 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..137069b --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index e69de29..b2ec6ec 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,73 @@ +[![REUSE status](https://api.reuse.software/badge/github.com/cap-java/cds-ai)](https://api.reuse.software/info/github.com/cap-java/cds-ai) + +# SAP Cloud Application Programming Model - AI Plugins for Java + +## About this project + +This repository contains a collection of AI plugins for [CAP Java](https://cap.cloud.sap/docs/java/) applications, leveraging [SAP AI Core](https://help.sap.com/docs/sap-ai-core) and the SAP-RPT-1 foundation model. + +### Plugins + +| Module | Description | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| [`cds-feature-ai-core`](cds-feature-ai-core/README.md) | Bridges CAP Java to SAP AI Core - resource group management, deployment lifecycle, configuration CRUD, and prediction API | +| [`cds-feature-recommendations`](cds-feature-recommendations/README.md) | AI-powered field recommendations for Fiori UIs in draft-enabled entities | +| [`cds-feature-sap-document-ai`](cds-feature-sap-document-ai/README.md) | SAP Document AI integration for asynchronous document information extraction via the DIE service | + +### Starter + +For the simplest setup, add the **`cds-starter-ai`** dependency which bundles both plugins: + +```xml + + com.sap.cds + cds-starter-ai + ${cds-ai.version} + +``` + +```json +"dependencies": { + "@cap-js/ai": "^1" +} +``` + +> Note: [`cds-feature-sap-document-ai`](cds-feature-sap-document-ai/README.md) is not part of `cds-starter-ai` yet - it will be added once the plugin supports multi-tenancy. Add it as an explicit dependency in your application if you need it today. + +## Prerequisites + +- Java 17+ +- CAP Java 4.9+ +- Node.js 20+ with `@sap/cds-dk` 9+ (for CDS build tooling) +- An [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding (for production use) + +Without an AI Core binding the plugins fall back to mock implementations for local development. + +## Samples + +A runnable CAP Java bookshop demonstrating all plugins together lives in [`samples/bookshop`](samples/bookshop). It provides an `AdminService` showcasing recommendations on draft-enabled Books, and a `SupplierInvoicesService` showcasing document extraction from supplier invoices. See the sample's own instructions for how to run it. + +## Local Development + +```bash +mvn clean install # build all modules +mvn test # run unit tests +``` + +For per-plugin details (configuration, programmatic API, multi-tenancy behaviour) see the individual module READMEs. For integration tests against a real AI Core instance see [`integration-tests/spring`](integration-tests/spring). + +## Support, Feedback, Contributing + +This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-java/cds-ai/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md). + +## Security / Disclosure + +If you find any bug that may be a security problem, please follow our instructions at [in our security policy](https://github.com/cap-java/cds-ai/security/policy) on how to report it. Please do not create GitHub issues for security-related doubts or problems. + +## Code of Conduct + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](https://github.com/cap-java/.github/blob/main/CODE_OF_CONDUCT.md) at all times. + +## Licensing + +Copyright 2026 SAP SE or an SAP affiliate company and cds-ai contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available via the REUSE tool. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..e0fac8d --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,11 @@ +version = 1 +SPDX-PackageName = "cds-ai" +SPDX-PackageSupplier = "ospo@sap.com" +SPDX-PackageDownloadLocation = "https://github.com/cap-java/cds-ai" +SPDX-PackageComment = "The code in this project may include calls to APIs (\"API Calls\") of\n SAP or third-party products or services developed outside of this project\n (\"External Products\").\n \"APIs\" means application programming interfaces, as well as their respective\n specifications and implementing code that allows software to communicate with\n other software.\n API Calls to External Products are not licensed under the open source license\n that governs this project. The use of such API Calls and related External\n Products are subject to applicable additional agreements with the relevant\n provider of the External Products. In no event shall the open source license\n that governs this project grant any rights in or to any External Products,or\n alter, expand or supersede any terms of the applicable additional agreements.\n If you have a valid license agreement with SAP for the use of a particular SAP\n External Product, then you may make use of any API Calls included in this\n project's code for that SAP External Product, subject to the terms of such\n license agreement. If you do not have a valid license agreement for the use of\n a particular SAP External Product, then you may only make use of any API Calls\n in this project for that SAP External Product for your internal, non-productive\n and non-commercial test and evaluation of such API Calls. Nothing herein grants\n you any rights to use or access any SAP External Product, or provide any third\n parties the right to use of access any SAP External Product, through API Calls." + +[[annotations]] +path = "**" +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 SAP SE or an SAP affiliate company and @cap-java/cds-ai contributors" +SPDX-License-Identifier = "Apache-2.0" diff --git a/cds-feature-ai-core/README.md b/cds-feature-ai-core/README.md new file mode 100644 index 0000000..886aa22 --- /dev/null +++ b/cds-feature-ai-core/README.md @@ -0,0 +1,113 @@ +# cds-feature-ai-core + +Bridges CAP Java applications to [SAP AI Core](https://help.sap.com/docs/sap-ai-core), providing resource group management, deployment lifecycle, configuration CRUD, and a prediction API. + +## Features + +- **`AICore` CDS Service** - Exposes resource groups, deployments, and configurations as CDS entities with full CRUD support +- **Multi-Tenancy** - Automatic per-tenant resource group creation/deletion on subscribe/unsubscribe +- **Deployment Management** - Auto-creates configurations and deployments for AI Core models with retry and backoff +- **Inference Client Factory** - Provides ready-to-use `ApiClient` instances scoped to a deployment for downstream foundation-model SDKs +- **Mock Fallback** - When no AI Core binding is detected, a mock implementation enables local development + +## Setup + +### Maven + +```xml + + com.sap.cds + cds-feature-ai-core + ${cds-ai.version} + runtime + +``` + +The plugin auto-registers via Java's `ServiceLoader` mechanism - no code changes required. + +### AI Core Binding + +In production, bind an SAP AI Core service instance to your application. Supported methods: + +- **Service binding** (Cloud Foundry / Kubernetes) +- **Environment variable** `AICORE_SERVICE_KEY` - for local hybrid testing (via `cds bind --exec`) + +Without a binding the plugin registers a mock implementation. + +## Configuration + +All configuration is under the `cds.ai.core` namespace in `application.yaml`: + +```yaml +cds: + ai: + core: + resourceGroup: default # Resource group for single-tenant mode + resourceGroupPrefix: "cds-" # Prefix for auto-created tenant resource groups + maxRetries: 10 # Max retry attempts for transient AI Core errors + initialDelayMs: 300 # Initial backoff delay (ms) +``` + +Multi-tenancy is auto-detected from CAP Java's standard `cds.multiTenancy.sidecar.url` setting +and the presence of a `DeploymentService`. No additional configuration flag is required. + +## CDS Service: `AICore` + +The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs as CDS entities: + +### Entities + +| Entity | Operations | Description | +| ----------------------- | -------------------- | ---------------------------------------------------------------- | +| `AICore.resourceGroups` | READ, CREATE, DELETE | Resource group lifecycle, supports label filtering by `tenantId` | +| `AICore.deployments` | READ, CREATE, DELETE | Deployment management with status tracking | +| `AICore.configurations` | READ, CREATE | Configuration management for scenarios and executables | + +### Programmatic API + +```java +// Get the resource group for the current tenant +String rgId = aiCoreService.resourceGroup(); + +// Get (or auto-create) a deployment ID for a model spec in the given resource group +String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1()); +``` + +## Multi-Tenancy + +When multi-tenancy is active (detected via `cds.multiTenancy.sidecar.url`): + +1. **Subscribe** - Creates resource group `{prefix}{tenantId}` with label `ext.ai.sap.com/CDS_TENANT_ID` +2. **Unsubscribe** - Deletes the tenant's resource group +3. **Isolation** - Each tenant's predictions use their own resource group and deployment + +The lifecycle hooks are registered automatically when multi-tenancy is enabled. + +## Programmatic Usage + +```java +// Obtain the service +AICoreService aiCore = runtime.getServiceCatalog() + .getService(AICoreService.class, AICoreService.DEFAULT_NAME); + +// Use for entity operations (AICoreService extends CqnService) +Result rgs = aiCore.run(Select.from("AICore.resourceGroups")); + +// Resolve a deployment and obtain a configured ApiClient for it +String resourceGroupId = aiCore.resourceGroup(); +String deploymentId = aiCore.deploymentId(resourceGroupId, RptModelSpec.rpt1()); +ApiClient client = aiCore.inferenceClient(resourceGroupId, deploymentId); +``` + +The `ApiClient` returned by `inferenceClient` is preconfigured with the AI Core +destination and the deployment URL; use it to construct foundation-model SDK +clients (for example `RptInferenceClient` from `cds-feature-recommendations`). + +## Related + +- [SAP AI Core Documentation](https://help.sap.com/docs/sap-ai-core) +- [SAP AI SDK for Java](https://github.com/SAP/ai-sdk-java) + +## Sample + +A runnable CAP Java sample demonstrating the AI plugins (which depend on this module) lives at [`samples/bookshop`](../samples/bookshop). diff --git a/cds-feature-ai-core/package-lock.json b/cds-feature-ai-core/package-lock.json new file mode 100644 index 0000000..abff26a --- /dev/null +++ b/cds-feature-ai-core/package-lock.json @@ -0,0 +1,1861 @@ +{ + "name": "cds-feature-ai-core-cds", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cds-feature-ai-core-cds", + "version": "1.0.0", + "devDependencies": { + "@sap/cds-dk": "9.9.1" + } + }, + "node_modules/@sap/cds-dk": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@sap/cds-dk/-/cds-dk-9.9.1.tgz", + "integrity": "sha512-cZoHI/ZhEVffmLo2k9Y/HMR5X+aGCpk60PwJJcZgoat8Kwk6dDl3mUDERhZORQUhp9FwOiyWmNujmNCV8YWWCg==", + "dev": true, + "hasShrinkwrap": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@cap-js/asyncapi": "^1.0.0", + "@cap-js/openapi": "^1.0.0", + "@sap/cds": "^8.3 || ^9", + "@sap/cds-mtxs": "^2 || ^3", + "@sap/hdi-deploy": "^5", + "express": "^4.22.1 || ^5", + "hdb": "^2.0.0", + "livereload-js": "^4.0.1", + "mustache": "^4.0.1", + "ws": "^8.4.2", + "xml-js": "^1.6.11", + "yaml": "^2" + }, + "bin": { + "cds": "bin/cds.js", + "cds-ts": "bin/cds-ts.js", + "cds-tsx": "bin/cds-tsx.js" + }, + "optionalDependencies": { + "@cap-js/sqlite": ">=1" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/asyncapi": { + "version": "1.0.3", + "integrity": "sha512-vZSWKAe+3qfvZDXV5SSFiObGWmqyS9MDyEADb5PLVT8kzO39qGaSDPv/GzI/gwvRfCayGAjU4ThiBKrFA7Gclg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/db-service": { + "version": "2.11.0", + "integrity": "sha512-sl33LcxZYAJgMCQZDw4lMGe4kWYq6685Xc6ze4qcoM+rd6aqiyVsSC6C7XH5yerXs7cVHhRC+Dgo8AsaapFzlQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "generic-pool": "^3.9.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/openapi": { + "version": "1.4.0", + "integrity": "sha512-/LRSwn4SDxAi3qKwl09zoOhEVGaPGlYOPz/0S3UBnaMJVvaLyPiKbbaOtOnrrgulUX5OXt+ujPIQznOsbTzuAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "pluralize": "^8.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/sqlite": { + "version": "2.4.0", + "integrity": "sha512-Ao+AzIN6BWHNpLbGxAzF79OezFNHzDG2srwiBABs0FYxIxEGkc2hg6ETo79pTTt66gcWtx7pWh/N9xk2M6SFBQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@cap-js/db-service": "^2.11.0", + "better-sqlite3": "^12.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8", + "sql.js": "^1.13.0" + }, + "peerDependenciesMeta": { + "sql.js": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@eslint/js": { + "version": "10.0.1", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds": { + "version": "9.9.1", + "integrity": "sha512-GqdsBsRkZThhpOyzj8ihf/jDmf/2zprZFgaun6ZymUw4/ahzjK/bbdd6eQ8txDuv88pnUl2HPFjvUVq3O/6hCA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/cds-compiler": "^6.4", + "@sap/cds-fiori": "^2", + "express": "^4.22.1 || ^5", + "yaml": "^2" + }, + "bin": { + "cds-deploy": "bin/deploy.js", + "cds-serve": "bin/serve.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@eslint/js": "^9 || ^10", + "tar": "^7.5.6" + }, + "peerDependenciesMeta": { + "tar": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-compiler": { + "version": "6.9.1", + "integrity": "sha512-j5C61t1mPhMW3vpD3LIRVn40DMiIF2XahOPeJIPjRpUiGMbQPdVreqAhiRHg39GYhSK6etlr5/MIx3a2ljtqHg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "bin": { + "cdsc": "bin/cdsc.js", + "cdshi": "bin/cdshi.js", + "cdsse": "bin/cdsse.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-fiori": { + "version": "2.3.0", + "integrity": "sha512-6oWov+DSpFrSTgxXR0dZhak6aZ/IVRZvaHERMi0EgSTzIJdlvZlpw3Kf18ePMcTrRrtEXwD4RIjKt8pbs0g2Hg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-mtxs": { + "version": "3.9.0", + "integrity": "sha512-U9H9NXQxlxSNwSD/6U59+Egn9LIE2SRdu8i5bZqEG2GB4xEU6csduy0kY4EWvi8XXD8onbFSgw4AA9SB4pN0Yg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/hdi-deploy": "^5" + }, + "bin": { + "cds-mtx": "bin/cds-mtx.js", + "cds-mtx-migrate": "bin/cds-mtx-migrate.js" + }, + "peerDependencies": { + "@sap/cds": ">=9" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi": { + "version": "4.8.0", + "integrity": "sha512-tkJmY2ffm6mt4/LFwRBihlQkMxNAXa3ngvRe2N/6+qLIsUNdrH/M03S5mkygXq56K+KoVVZYuradajCusMWwsw==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "async": "^3.2.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.5", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi-deploy": { + "version": "5.6.1", + "integrity": "sha512-+qQ7qwG8lko303L5yRj2dud/nDAVuVblV/mmzJT44oPbF0Nry18eD2LUS23hFeuxjRa7rYK5YKQ8ffGgWxVrYQ==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "@sap/hdi": "^4.8.0", + "@sap/xsenv": "^6.0.0", + "async": "^3.2.6", + "dotenv": "^16.4.5", + "handlebars": "^4.7.8", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=18.x" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.6", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/xsenv": { + "version": "6.2.0", + "integrity": "sha512-8jrsX1OAM3YUqGU+4deggqvkxrBrHAPYEllBX0YJfWNffgxSZKHG75bRd/RV6hxPwulPL0DeHfd2eYJMeY5gdw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE file", + "dependencies": { + "debug": "4.4.3", + "node-cache": "^5.1.2", + "verror": "1.10.1" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/accepts": { + "version": "2.0.0", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/assert-plus": { + "version": "1.0.0", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/async": { + "version": "3.2.6", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/base64-js": { + "version": "1.5.1", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/better-sqlite3": { + "version": "12.9.0", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/@sap/cds-dk/node_modules/bindings": { + "version": "1.5.0", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/bl": { + "version": "4.1.0", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/body-parser": { + "version": "2.2.2", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/braces": { + "version": "3.0.3", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/buffer": { + "version": "5.7.1", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/@sap/cds-dk/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/chownr": { + "version": "1.1.4", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/clone": { + "version": "2.1.2", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-disposition": { + "version": "1.1.0", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie-signature": { + "version": "1.2.2", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/core-util-is": { + "version": "1.0.2", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/decompress-response": { + "version": "6.0.0", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/deep-extend": { + "version": "0.6.0", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/detect-libc": { + "version": "2.1.2", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/dotenv": { + "version": "16.6.1", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sap/cds-dk/node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/end-of-stream": { + "version": "1.4.5", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-object-atoms": { + "version": "1.1.1", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/etag": { + "version": "1.8.1", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/expand-template": { + "version": "2.0.3", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/express": { + "version": "5.2.1", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/extsprintf": { + "version": "1.4.1", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/file-uri-to-path": { + "version": "1.0.0", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/fill-range": { + "version": "7.1.1", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/finalhandler": { + "version": "2.1.1", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/fresh": { + "version": "2.0.0", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/fs-constants": { + "version": "1.0.0", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/generic-pool": { + "version": "3.9.0", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/github-from-package": { + "version": "0.0.0", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/handlebars": { + "version": "4.7.9", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/hasown": { + "version": "2.0.3", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb": { + "version": "2.27.1", + "integrity": "sha512-xYL/W+fq2TyGHyzm8muolQnw8tdh4+2NQ8mQP2FpLSuhfJ8l0jQNSUZoAXic7NfMEan1Jvf8V1L4blwkgTc6+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "iconv-lite": "0.7.0" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "lz4-wasm-nodejs": "0.9.2" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb/node_modules/iconv-lite": { + "version": "0.7.0", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/iconv-lite": { + "version": "0.7.2", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/ieee754": { + "version": "1.2.1", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ini": { + "version": "1.3.8", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-number": { + "version": "7.0.0", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-promise": { + "version": "4.0.0", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/livereload-js": { + "version": "4.0.2", + "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/lz4-wasm-nodejs": { + "version": "0.9.2", + "integrity": "sha512-hSwgJPS98q/Oe/89Y1OxzeA/UdnASG8GvldRyKa7aZyoAFCC8VPRtViBSava7wWC66WocjUwBpWau2rEmyFPsw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/math-intrinsics": { + "version": "1.1.0", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/media-typer": { + "version": "1.1.0", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/merge-descriptors": { + "version": "2.0.0", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch": { + "version": "4.0.8", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-types": { + "version": "3.0.2", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/mimic-response": { + "version": "3.1.0", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/minimist": { + "version": "1.2.8", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/mkdirp-classic": { + "version": "0.5.3", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/mustache": { + "version": "4.2.0", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/@sap/cds-dk/node_modules/napi-build-utils": { + "version": "2.0.0", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/negotiator": { + "version": "1.0.0", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/neo-async": { + "version": "2.6.2", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/node-abi": { + "version": "3.92.0", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/node-cache": { + "version": "5.1.2", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/object-inspect": { + "version": "1.13.4", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/on-finished": { + "version": "2.4.1", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/once": { + "version": "1.4.0", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/@sap/cds-dk/node_modules/parseurl": { + "version": "1.3.3", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/path-to-regexp": { + "version": "8.4.2", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/pluralize": { + "version": "8.0.0", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sap/cds-dk/node_modules/prebuild-install": { + "version": "7.1.3", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/proxy-addr": { + "version": "2.0.7", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/pump": { + "version": "3.0.4", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/@sap/cds-dk/node_modules/qs": { + "version": "6.15.1", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/raw-body": { + "version": "3.0.2", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/rc": { + "version": "1.2.8", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/readable-stream": { + "version": "3.6.2", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sap/cds-dk/node_modules/router": { + "version": "2.2.0", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sap/cds-dk/node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/sax": { + "version": "1.6.0", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/semver": { + "version": "7.7.4", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/send": { + "version": "1.2.1", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/serve-static": { + "version": "2.2.1", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/side-channel": { + "version": "1.1.0", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-weakmap": { + "version": "1.0.2", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/simple-concat": { + "version": "1.0.1", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/simple-get": { + "version": "4.0.1", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/string_decoder": { + "version": "1.3.0", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/strip-json-comments": { + "version": "2.0.1", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-fs": { + "version": "2.1.4", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-stream": { + "version": "2.2.0", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/to-regex-range": { + "version": "5.0.1", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/tunnel-agent": { + "version": "0.6.0", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@sap/cds-dk/node_modules/type-is": { + "version": "2.0.1", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/uglify-js": { + "version": "3.19.3", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/util-deprecate": { + "version": "1.0.2", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/vary": { + "version": "1.1.2", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/verror": { + "version": "1.10.1", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/wordwrap": { + "version": "1.0.0", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/wrappy": { + "version": "1.0.2", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ws": { + "version": "8.20.0", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/xml-js": { + "version": "1.6.11", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/yaml": { + "version": "2.8.4", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/cds-feature-ai-core/package.json b/cds-feature-ai-core/package.json new file mode 100644 index 0000000..820b858 --- /dev/null +++ b/cds-feature-ai-core/package.json @@ -0,0 +1,9 @@ +{ + "name": "cds-feature-ai-core-cds", + "version": "1.0.0", + "private": true, + "description": "CDS build dependencies for cds-feature-ai-core. Pulled in by Maven (cds-maven-plugin npm goal) so a fresh `mvn install` is hermetic and does not require a globally installed @sap/cds-dk.", + "devDependencies": { + "@sap/cds-dk": "9.9.1" + } +} diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml new file mode 100644 index 0000000..ebdb164 --- /dev/null +++ b/cds-feature-ai-core/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + + + com.sap.cds + cds-ai-root + ${revision} + + + cds-feature-ai-core + jar + + CDS Feature AI Core + AI client infrastructure for CAP Java (AIClient, retry, AI Core setup) + + + + com.sap.cds + cds-services-api + + + + com.sap.cds + cds-services-utils + + + + com.sap.ai.sdk + core + ${ai-sdk.version} + + + + io.github.resilience4j + resilience4j-retry + + + + com.github.ben-manes.caffeine + caffeine + + + + com.sap.cds + cds-services-impl + + + + + ${project.artifactId} + + + src/test/resources + + + src/gen/srv/src/main/resources + + + + + com.sap.cds + cds-maven-plugin + + + cds.install-node + + install-node + + + + cds.npm-ci + + npm + + + ci + + + + cds.build + + cds + + + ./src/main/resources/cds/com.sap.cds/ai + + build --for java --src ./ --dest ../../../../../../gen/srv + + + + + cds.generate + + generate + + + com.sap.cds.feature.aicore.generated.cds4j + ${project.basedir}/src/gen/srv/src/main/resources/edmx/csn.json + + AICore.** + + + + + + + org.jacoco + jacoco-maven-plugin + + + jacoco-initialize + + prepare-agent + + + + jacoco-site-report + + report + + verify + + + + + + + diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java new file mode 100644 index 0000000..04857c0 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; + +/** + * Typed {@link EventContext} for the {@code deploymentId} event. + * + *

Emitted on the AI Core service to resolve (or create) a deployment matching the given spec + * inside the given resource group. The ON handler performs cache lookup, retry, configuration + * creation, deployment creation and polling. + */ +@EventName(DeploymentIdContext.EVENT) +public interface DeploymentIdContext extends EventContext { + + /** Event name constant. */ + String EVENT = "deploymentId"; + + /** Returns the resource group ID to operate in. */ + String getResourceGroupId(); + + /** Sets the resource group ID to operate in. */ + void setResourceGroupId(String resourceGroupId); + + /** Returns the deployment specification. */ + ModelDeploymentSpec getSpec(); + + /** Sets the deployment specification. */ + void setSpec(ModelDeploymentSpec spec); + + /** Returns the resolved deployment ID (set by the ON handler). */ + String getResult(); + + /** Sets the resolved deployment ID. */ + void setResult(String deploymentId); + + /** Creates a new context instance. */ + static DeploymentIdContext create() { + return EventContext.create(DeploymentIdContext.class, null); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java new file mode 100644 index 0000000..8ed8710 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; + +/** + * Typed {@link EventContext} for the {@code inferenceClient} event. + * + *

Emitted on the AI Core service to build an {@link ApiClient} preconfigured with the inference + * destination for the given deployment. + */ +@EventName(InferenceClientContext.EVENT) +public interface InferenceClientContext extends EventContext { + + /** Event name constant. */ + String EVENT = "inferenceClient"; + + /** Returns the resource group ID containing the deployment. */ + String getResourceGroupId(); + + /** Sets the resource group ID containing the deployment. */ + void setResourceGroupId(String resourceGroupId); + + /** Returns the deployment ID. */ + String getDeploymentId(); + + /** Sets the deployment ID. */ + void setDeploymentId(String deploymentId); + + /** Returns the configured {@link ApiClient} (set by the ON handler). */ + ApiClient getResult(); + + /** Sets the configured {@link ApiClient}. */ + void setResult(ApiClient client); + + /** Creates a new context instance. */ + static InferenceClientContext create() { + return EventContext.create(InferenceClientContext.class, null); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java new file mode 100644 index 0000000..72b29f3 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java @@ -0,0 +1,34 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.ai.sdk.core.model.AiDeployment; +import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; +import java.util.List; +import java.util.function.Predicate; + +/** + * Describes a target AI Core deployment used by the {@code deploymentId} event to look up or create + * a deployment inside a resource group. + * + *

The spec carries the AI Core scenario/executable identification, the human-readable + * configuration name (used as a stable key for caching and idempotent reuse), the parameter + * bindings to apply when a configuration must be created, and a predicate that decides whether an + * already existing deployment is acceptable for reuse. + * + * @param scenarioId AI Core scenario ID (e.g. {@code "foundation-models"}) + * @param executableId AI Core executable ID inside the scenario + * @param configurationName human-readable configuration name; doubles as cache key per resource + * group + * @param parameterBindings parameter bindings applied when a new configuration must be created; + * ignored if a configuration with the same {@code configurationName} already exists + * @param matchesExisting predicate that returns {@code true} for an existing {@link AiDeployment} + * considered equivalent to this spec; typically checks model and version + */ +public record ModelDeploymentSpec( + String scenarioId, + String executableId, + String configurationName, + List parameterBindings, + Predicate matchesExisting) {} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java new file mode 100644 index 0000000..9d08328 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; + +/** + * Typed {@link EventContext} for the {@code resourceGroup} event. + * + *

Emitted on the AI Core service to resolve the AI Core resource group ID for the current + * tenant. In multi-tenancy mode, the resource group is created on-demand if it does not exist. In + * single-tenancy mode, the configured default resource group is returned. + * + *

If {@link #getTenantId()} is non-null, the handler uses the explicit tenant ID. Otherwise, the + * current tenant is read from the {@code RequestContext}. + */ +@EventName(ResourceGroupContext.EVENT) +public interface ResourceGroupContext extends EventContext { + + /** Event name constant. */ + String EVENT = "resourceGroup"; + + /** + * Returns the explicit tenant ID (optional). If {@code null}, the handler reads the tenant from + * the current {@code RequestContext}. + */ + String getTenantId(); + + /** Sets an explicit tenant ID. */ + void setTenantId(String tenantId); + + /** Returns the resolved resource group ID (set by the ON handler). */ + String getResult(); + + /** Sets the resolved resource group ID. */ + void setResult(String resourceGroupId); + + /** Creates a new context instance. */ + static ResourceGroupContext create() { + return EventContext.create(ResourceGroupContext.class, null); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/package-info.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/package-info.java new file mode 100644 index 0000000..9ecf514 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/package-info.java @@ -0,0 +1,10 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +/** + * Public API of the {@code cds-feature-ai-core} plugin. + * + *

Types in this package form the stable contract that applications and other plugins program + * against. Implementation classes live in sibling internal packages and may change without notice. + */ +package com.sap.cds.feature.aicore.api; diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java new file mode 100644 index 0000000..e004b12 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java @@ -0,0 +1,23 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; + +/** + * Holder for the AI Core SDK API clients, built once from the service binding at startup. + * + * @param deploymentApi client for deployment CRUD operations + * @param configurationApi client for configuration CRUD operations + * @param resourceGroupApi client for resource-group CRUD operations + * @param sdkService the AI Core SDK service for inference destination resolution + */ +public record AICoreClients( + DeploymentApi deploymentApi, + ConfigurationApi configurationApi, + ResourceGroupApi resourceGroupApi, + AiCoreService sdkService) {} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java new file mode 100644 index 0000000..84ded92 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java @@ -0,0 +1,58 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.cds.services.environment.CdsEnvironment; + +/** + * Immutable configuration for the AI Core plugin, read once from {@link CdsEnvironment} at startup. + * + * @param defaultResourceGroup the resource group to use when multi-tenancy is disabled + * @param resourceGroupPrefix prefix for tenant-specific resource groups (e.g. "cds-") + * @param maxRetries max retry attempts for transient AI Core errors + * @param initialDelayMs initial backoff delay in milliseconds + * @param multiTenancyEnabled whether multi-tenancy is active + */ +public record AICoreConfig( + String defaultResourceGroup, + String resourceGroupPrefix, + int maxRetries, + long initialDelayMs, + boolean multiTenancyEnabled) { + + /** The AI Core resource-group label key used to associate groups with CDS tenants. */ + public static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; + + private static final String DEFAULT_RESOURCE_GROUP = "default"; + private static final String DEFAULT_RESOURCE_GROUP_PREFIX = "cds-"; + private static final int DEFAULT_MAX_RETRIES = 10; + private static final long DEFAULT_INITIAL_DELAY_MS = 300; + + public AICoreConfig { + if (maxRetries < 1) { + throw new IllegalArgumentException("cds.ai.core.maxRetries must be >= 1, got " + maxRetries); + } + if (initialDelayMs < 1) { + throw new IllegalArgumentException( + "cds.ai.core.initialDelayMs must be >= 1, got " + initialDelayMs); + } + if (defaultResourceGroup == null || defaultResourceGroup.isBlank()) { + throw new IllegalArgumentException("cds.ai.core.resourceGroup must not be blank"); + } + if (resourceGroupPrefix == null) { + throw new IllegalArgumentException("cds.ai.core.resourceGroupPrefix must not be null"); + } + } + + /** Creates an {@code AICoreConfig} from the runtime environment properties. */ + public static AICoreConfig from(CdsEnvironment env, boolean multiTenancyEnabled) { + return new AICoreConfig( + env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP), + env.getProperty( + "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX), + env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES), + env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS), + multiTenancyEnabled); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java new file mode 100644 index 0000000..8ad1bda --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -0,0 +1,153 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; +import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler; +import com.sap.cds.feature.aicore.core.handler.ActionHandler; +import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; +import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; +import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; +import com.sap.cds.feature.aicore.core.handler.MockAICoreSetupHandler; +import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; +import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; +import com.sap.cds.services.mt.DeploymentService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link CdsRuntimeConfiguration} that wires the {@code AICore} remote service and its event + * handlers into the CAP Java runtime. + * + *

In the {@link #environment} phase, a {@link RemoteServiceConfig} entry for "AICore" is + * injected into the runtime properties so the framework's {@code RemoteServiceConfiguration} + * auto-creates the service instance from the CDS model. This follows the same pattern used by + * {@code cds-feature-notifications}. + * + *

In the {@link #eventHandlers} phase, production or mock handlers are registered depending on + * whether an AI Core service binding is present. + */ +public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class); + + private AICoreConfig config; + private AICoreClients clients; + private DeploymentResolver resolver; + + /** + * Injects a {@link RemoteServiceConfig} for "AICore" into the runtime properties. This runs + * before all {@code services()} methods, ensuring the framework's {@code + * RemoteServiceConfiguration} will auto-create a {@code RemoteServiceImpl} for the AICore CDS + * service definition. + */ + @Override + public void environment(CdsRuntimeConfigurer configurer) { + RemoteServiceConfig remoteConfig = new RemoteServiceConfig(AICore_.CDS_NAME); + remoteConfig.setModel(AICore_.CDS_NAME); + configurer + .getCdsRuntime() + .getEnvironment() + .getCdsProperties() + .getRemote() + .getServices() + .putIfAbsent(AICore_.CDS_NAME, remoteConfig); + } + + @Override + public void services(CdsRuntimeConfigurer configurer) { + CdsRuntime runtime = configurer.getCdsRuntime(); + + if (!hasAICoreModel(runtime)) { + logger.debug("AICore CDS model not found in runtime model - skipping handler setup."); + return; + } + + boolean hasBinding = hasAICoreBinding(runtime); + boolean multiTenancyEnabled = detectMultiTenancy(runtime); + + this.config = AICoreConfig.from(runtime.getEnvironment(), multiTenancyEnabled); + + if (hasBinding) { + DeploymentApi deploymentApi = new DeploymentApi(); + ConfigurationApi configurationApi = new ConfigurationApi(); + ResourceGroupApi resourceGroupApi = new ResourceGroupApi(); + AiCoreService sdkService = new AiCoreService(); + + this.clients = + new AICoreClients(deploymentApi, configurationApi, resourceGroupApi, sdkService); + this.resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + logger.info("AI Core binding detected - production handlers will be registered."); + } else { + logger.info("No AI Core binding found - mock handlers will be registered."); + } + } + + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + if (config == null) { + return; // No AICore model - services() skipped + } + + if (clients != null) { + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); + configurer.eventHandler(new ActionHandler(config, clients, resolver)); + logger.debug("Registered production AI Core event handlers."); + + if (config.multiTenancyEnabled()) { + configurer.eventHandler(new AICoreSetupHandler(clients, resolver)); + logger.debug("Registered AI Core setup handler for MTX subscribe/unsubscribe."); + } + } else { + MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config); + configurer.eventHandler(new MockEntityHandler()); + configurer.eventHandler(mockApiHandler); + logger.debug("Registered mock AI Core event handlers."); + + if (config.multiTenancyEnabled()) { + configurer.eventHandler(new MockAICoreSetupHandler(mockApiHandler)); + logger.debug("Registered mock AI Core setup handler for MTX subscribe/unsubscribe."); + } + } + } + + private static boolean hasAICoreModel(CdsRuntime runtime) { + return runtime.getCdsModel().findService(AICore_.CDS_NAME).isPresent(); + } + + private static boolean hasAICoreBinding(CdsRuntime runtime) { + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + } + + private static boolean detectMultiTenancy(CdsRuntime runtime) { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + if (sidecarUrl != null && !sidecarUrl.isBlank()) { + return true; + } + return runtime + .getServiceCatalog() + .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) + != null; + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java new file mode 100644 index 0000000..1db6136 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java @@ -0,0 +1,252 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiDeploymentStatus; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stateful component that manages tenant-to-resource-group and resource-group-to-deployment caches, + * per-key locks, and retry policies for AI Core operations. + * + *

Handlers interact with this class through intention-revealing operations ({@link + * #resolveResourceGroup}, {@link #resolveDeployment}, {@link #invalidateTenant}) instead of + * manipulating caches and locks directly. + */ +public class DeploymentResolver { + + private static final Logger logger = LoggerFactory.getLogger(DeploymentResolver.class); + + private static final Duration DEFAULT_CACHE_EXPIRY = Duration.ofHours(1); + private static final int DEFAULT_CACHE_MAX_SIZE = 10_000; + private static final long MAX_INTERVAL_MS = 30_000L; + + private final Cache tenantResourceGroupCache; + private final Cache deploymentCache; + + /** + * Per-cache-key monitors guarding deployment lookup/creation. Stored in a {@link + * ConcurrentHashMap} (not a Caffeine cache) so that two threads asking for the same key are + * guaranteed to obtain the same monitor instance — locks must never live in a + * size/time-evicting cache, otherwise concurrent callers can synchronize on different objects and + * race to create duplicate AI Core deployments. + */ + private final ConcurrentHashMap deploymentLocks = new ConcurrentHashMap<>(); + + private final AICoreConfig config; + private final DeploymentApi deploymentApi; + private final ResourceGroupApi resourceGroupApi; + private final Retry retry; + + public DeploymentResolver( + AICoreConfig config, DeploymentApi deploymentApi, ResourceGroupApi resourceGroupApi) { + this.config = config; + this.deploymentApi = deploymentApi; + this.resourceGroupApi = resourceGroupApi; + this.retry = buildRetry(config.maxRetries(), config.initialDelayMs()); + this.tenantResourceGroupCache = newCache(); + this.deploymentCache = newCache(); + } + + // ────────────────────────────────────────────────────────────────────────── + // Resource group resolution + // ────────────────────────────────────────────────────────────────────────── + + /** + * Resolves the resource group for a tenant. Returns the configured default resource group if + * multi-tenancy is disabled or tenant is {@code null}. Otherwise looks up (or creates) the + * tenant's resource group via the AI Core API, caching the result. Thread-safe. + * + * @param tenantId the CDS tenant identifier (may be {@code null}) + * @return the AI Core resource group ID + */ + public String resolveResourceGroup(String tenantId) { + if (!config.multiTenancyEnabled() || tenantId == null) { + return config.defaultResourceGroup(); + } + return tenantResourceGroupCache.get(tenantId, this::findOrCreateResourceGroup); + } + + // ────────────────────────────────────────────────────────────────────────── + // Deployment resolution + // ────────────────────────────────────────────────────────────────────────── + + /** + * Resolves a deployment ID for the given spec within a resource group. On cache hit, validates + * via {@link DeploymentApi#get} that the deployment is still RUNNING or PENDING. On cache miss or + * stale entry, acquires a per-key lock and calls the {@code loader} to find or create the + * deployment. The result is cached. + * + * @param resourceGroupId the AI Core resource group + * @param spec the deployment specification + * @param loader supplier that finds an existing or creates a new deployment — called under lock + * on cache miss + * @return the deployment ID + */ + public String resolveDeployment( + String resourceGroupId, ModelDeploymentSpec spec, Supplier loader) { + String cacheKey = deploymentCacheKey(resourceGroupId, spec); + Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object()); + + synchronized (lock) { + String cached = deploymentCache.getIfPresent(cacheKey); + if (cached != null) { + if (validateCachedDeployment(resourceGroupId, cached)) { + return cached; + } + deploymentCache.invalidate(cacheKey); + } + + String deploymentId = loader.get(); + deploymentCache.put(cacheKey, deploymentId); + return deploymentId; + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Cache management + // ────────────────────────────────────────────────────────────────────────── + + /** + * Evicts all cache entries associated with the given tenant: the resource-group mapping, all + * deployments in that resource group, and their lock entries. + */ + public void invalidateTenant(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); + if (resourceGroupId != null) { + String prefix = resourceGroupId + "::"; + deploymentCache + .asMap() + .keySet() + .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } + + /** Returns the shared {@link Retry} for wrapping transient AI Core operations. */ + public Retry getRetry() { + return retry; + } + + /** + * Returns an unmodifiable view of the tenant-to-resource-group cache. Primarily for diagnostics + * and the setup handler's unsubscribe logic. + */ + public Map getTenantResourceGroupCacheView() { + return Collections.unmodifiableMap(tenantResourceGroupCache.asMap()); + } + + /** Builds the cache key for deployment lookups. */ + static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { + return resourceGroupId + "::" + spec.configurationName(); + } + + /** Returns whether the given {@link OpenApiRequestException} indicates a transient state. */ + public static boolean notReadyYet(OpenApiRequestException e) { + Throwable t = e; + while (t != null) { + if (t instanceof OpenApiRequestException oae) { + Integer code = oae.statusCode(); + if (code != null && (code == 403 || code == 404 || code == 412)) { + return true; + } + } + t = t.getCause(); + } + return false; + } + + // ────────────────────────────────────────────────────────────────────────── + // Internal + // ────────────────────────────────────────────────────────────────────────── + + private String findOrCreateResourceGroup(String tenantId) { + List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); + BckndResourceGroupList result = + resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); + List resources = result.getResources(); + if (resources != null && !resources.isEmpty()) { + return resources.get(0).getResourceGroupId(); + } + String resourceGroupId = config.resourceGroupPrefix() + tenantId; + BckndResourceGroupLabel label = + BckndResourceGroupLabel.create().key(AICoreConfig.TENANT_LABEL_KEY).value(tenantId); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create() + .resourceGroupId(resourceGroupId) + .labels(List.of(label)); + try { + resourceGroupApi.create(request); + logger.debug("Created resource group {} for tenant {}", resourceGroupId, tenantId); + } catch (OpenApiRequestException e) { + if (e.statusCode() != null && e.statusCode() == 409) { + logger.debug("Resource group {} already exists (409 Conflict), reusing", resourceGroupId); + } else { + throw e; + } + } + return resourceGroupId; + } + + /** + * Validates that a cached deployment ID is still active (RUNNING or PENDING). Returns {@code + * true} if valid, {@code false} if stale (404). Throws on unexpected errors so the caller's + * retry/backoff policy can handle them. + */ + private boolean validateCachedDeployment(String resourceGroupId, String deploymentId) { + try { + var current = deploymentApi.get(resourceGroupId, deploymentId); + return AiDeploymentStatus.RUNNING.equals(current.getStatus()) + || AiDeploymentStatus.PENDING.equals(current.getStatus()); + } catch (OpenApiRequestException e) { + Integer status = e.statusCode(); + if (status != null && status == 404) { + logger.debug( + "Cached deployment {} in resource group {} no longer exists (404), invalidating", + deploymentId, + resourceGroupId); + return false; + } + throw e; + } + } + + private static Cache newCache() { + return Caffeine.newBuilder() + .maximumSize(DEFAULT_CACHE_MAX_SIZE) + .expireAfterAccess(DEFAULT_CACHE_EXPIRY) + .build(); + } + + private static Retry buildRetry(int maxAttempts, long initialDelayMs) { + RetryConfig config = + RetryConfig.custom() + .maxAttempts(maxAttempts) + .intervalFunction( + IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0, MAX_INTERVAL_MS)) + .retryOnException(e -> e instanceof OpenApiRequestException oae && notReadyYet(oae)) + .build(); + return Retry.of("aicore", config); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java new file mode 100644 index 0000000..2ab2ee7 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -0,0 +1,216 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.model.AiConfigurationBaseData; +import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.ai.sdk.core.model.AiDeployment; +import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentList; +import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; +import com.sap.ai.sdk.core.model.AiDeploymentStatus; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ON handler for the AI Core service API events ({@code resourceGroup}, {@code deploymentId}, + * {@code inferenceClient}). + * + *

Contains the business logic for deployment discovery/creation and inference client + * construction. Resource-group resolution is delegated to {@link DeploymentResolver}. + */ +@ServiceName(AICore_.CDS_NAME) +public class AICoreApiHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class); + + private final AICoreConfig config; + private final AICoreClients clients; + private final DeploymentResolver resolver; + + public AICoreApiHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + this.config = config; + this.clients = clients; + this.resolver = resolver; + } + + @On + public void onResourceGroup(ResourceGroupContext context) { + String tenantId = context.getTenantId(); + if (tenantId == null) { + tenantId = context.getUserInfo().getTenant(); + } + context.setResult(resolver.resolveResourceGroup(tenantId)); + } + + @On + public void onDeploymentId(DeploymentIdContext context) { + String resourceGroupId = context.getResourceGroupId(); + ModelDeploymentSpec spec = context.getSpec(); + + String deploymentId = + resolver.resolveDeployment( + resourceGroupId, spec, () -> findOrCreateDeployment(resourceGroupId, spec)); + context.setResult(deploymentId); + } + + @On + public void onInferenceClient(InferenceClientContext context) { + var destination = + clients + .sdkService() + .getInferenceDestination(context.getResourceGroupId()) + .usingDeploymentId(context.getDeploymentId()); + logger.debug("Inference destination URI: {}", destination.getUri()); + context.setResult(ApiClient.create(destination)); + } + + // ────────────────────────────────────────────────────────────────────────── + // Deployment business logic + // ────────────────────────────────────────────────────────────────────────── + + private String findOrCreateDeployment(String resourceGroupId, ModelDeploymentSpec spec) { + AiDeploymentList deploymentList = queryDeploymentsUntilReady(resourceGroupId, spec); + Optional existing = + deploymentList.getResources().stream() + .filter( + d -> + spec.configurationName().equals(d.getConfigurationName()) + && spec.matchesExisting().test(d) + && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) + || AiDeploymentStatus.PENDING.equals(d.getStatus()))) + .findFirst() + .map(AiDeployment::getId); + if (existing.isPresent()) { + return existing.get(); + } + return createDeployment(resourceGroupId, spec); + } + + private String createDeployment(String resourceGroupId, ModelDeploymentSpec spec) { + String configId = findOrCreateConfiguration(resourceGroupId, spec); + + // Retry only the creation call — transient 403/412 on fresh resource groups. + // Once we have a deployment ID, polling is handled separately to avoid + // creating orphaned deployments on poll timeout. + String deploymentId = + Retry.decorateSupplier( + resolver.getRetry(), + () -> { + var deployRequest = + AiDeploymentCreationRequest.create().configurationId(configId); + var response = clients.deploymentApi().create(resourceGroupId, deployRequest); + logger.debug( + "Created deployment {} ({}) in resource group {}", + response.getId(), + spec.configurationName(), + resourceGroupId); + return response.getId(); + }) + .get(); + + return pollUntilRunning(resourceGroupId, deploymentId); + } + + private String findOrCreateConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { + AiConfigurationList configList = + clients + .configurationApi() + .query(resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); + return configList.getResources().stream() + .filter(c -> spec.configurationName().equals(c.getName())) + .findFirst() + .map( + c -> { + logger.debug( + "Reusing existing configuration {} ({}) in resource group {}", + c.getId(), + spec.configurationName(), + resourceGroupId); + return c.getId(); + }) + .orElseGet(() -> createConfiguration(resourceGroupId, spec)); + } + + private String createConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { + AiConfigurationBaseData configRequest = + AiConfigurationBaseData.create() + .name(spec.configurationName()) + .executableId(spec.executableId()) + .scenarioId(spec.scenarioId()) + .parameterBindings(spec.parameterBindings()); + String configId = clients.configurationApi().create(resourceGroupId, configRequest).getId(); + logger.debug( + "Created configuration {} ({}) in resource group {}", + configId, + spec.configurationName(), + resourceGroupId); + return configId; + } + + private String pollUntilRunning(String resourceGroupId, String deploymentId) { + Retry pollRetry = + Retry.of( + "pollDeployment-" + deploymentId, + RetryConfig.custom() + .maxAttempts(config.maxRetries()) + .intervalFunction( + IntervalFunction.ofExponentialBackoff(config.initialDelayMs(), 2.0)) + .retryOnResult( + deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus())) + .retryOnException(e -> false) + .build()); + + AiDeploymentResponseWithDetails result = + Retry.decorateSupplier( + pollRetry, + () -> { + var current = clients.deploymentApi().get(resourceGroupId, deploymentId); + logger.debug("Deployment {} status: {}", deploymentId, current.getStatus()); + return current; + }) + .get(); + + if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) { + return deploymentId; + } + logger.error( + "Deployment {} in resource group {} did not reach RUNNING status after {} retries", + deploymentId, + resourceGroupId, + config.maxRetries()); + throw new ServiceException( + ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available"); + } + + private AiDeploymentList queryDeploymentsUntilReady( + String resourceGroupId, ModelDeploymentSpec spec) { + Retry retry = resolver.getRetry(); + return Retry.decorateSupplier( + retry, + () -> + clients + .deploymentApi() + .query(resourceGroupId, null, null, spec.scenarioId(), null, null, null, null)) + .get(); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java new file mode 100644 index 0000000..1c07e5f --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java @@ -0,0 +1,125 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.DeploymentService; +import com.sap.cds.services.mt.SubscribeEventContext; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(DeploymentService.DEFAULT_NAME) +public class AICoreSetupHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(AICoreSetupHandler.class); + + private final AICoreClients clients; + private final DeploymentResolver resolver; + + public AICoreSetupHandler(AICoreClients clients, DeploymentResolver resolver) { + this.clients = clients; + this.resolver = resolver; + } + + @After + @HandlerOrder(HandlerOrder.LATE) + public void afterSubscribe(SubscribeEventContext context) { + String tenantId = context.getTenant(); + logger.debug("Creating AI Core resources for tenant {}", tenantId); + try { + String resourceGroupId = resolver.resolveResourceGroup(tenantId); + logger.info("Ensured AI Core resource group {} for tenant {}", resourceGroupId, tenantId); + } catch (Exception e) { + throw new ServiceException( + ErrorStatuses.SERVER_ERROR, + "Failed to create AI Core resources for tenant: {}", + tenantId, + e); + } + } + + @Before + @HandlerOrder(HandlerOrder.EARLY) + public void beforeUnsubscribe(UnsubscribeEventContext context) { + String tenantId = context.getTenant(); + logger.debug("Deleting AI Core resources for tenant {}", tenantId); + try { + deleteResourceGroupForTenant(tenantId); + } finally { + // Always evict cache entries so a retry won't reuse stale state. + resolver.invalidateTenant(tenantId); + } + } + + private void deleteResourceGroupForTenant(String tenantId) { + String resourceGroupId = resolveResourceGroupId(tenantId); + if (resourceGroupId == null) { + logger.info( + "No AI Core resource group found for tenant {} (already deleted), nothing to do", + tenantId); + return; + } + try { + clients.resourceGroupApi().delete(resourceGroupId); + logger.info("Deleted AI Core resource group {} for tenant {}", resourceGroupId, tenantId); + } catch (OpenApiRequestException e) { + if (e.statusCode() != null && e.statusCode() == 404) { + logger.info( + "AI Core resource group {} for tenant {} already deleted (404), treating as success", + resourceGroupId, + tenantId); + return; + } + throw new ServiceException( + ErrorStatuses.SERVER_ERROR, + "Failed to delete AI Core resource group {} for tenant {}", + resourceGroupId, + tenantId, + e); + } + } + + /** + * Resolves the resource-group ID for the tenant, first via the in-memory cache, then via the AI + * Core API filtered by the tenant label. Returns {@code null} if no resource group is found. + */ + private String resolveResourceGroupId(String tenantId) { + String cached = resolver.getTenantResourceGroupCacheView().get(tenantId); + if (cached != null) { + return cached; + } + logger.debug( + "No cached resource group for tenant {}, falling back to AI Core lookup", tenantId); + List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); + BckndResourceGroupList result; + try { + result = clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); + } catch (OpenApiRequestException e) { + throw new ServiceException( + ErrorStatuses.SERVER_ERROR, + "Failed to look up AI Core resource group for tenant {}", + tenantId, + e); + } + List resources = result.getResources(); + if (resources == null || resources.isEmpty()) { + return null; + } + return resources.get(0).getResourceGroupId(); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java new file mode 100644 index 0000000..fc3392d --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -0,0 +1,94 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.request.UserInfo; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +abstract class AbstractCrudHandler implements EventHandler { + + protected final AICoreConfig config; + protected final AICoreClients clients; + protected final DeploymentResolver resolver; + + protected AbstractCrudHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + this.config = config; + this.clients = clients; + this.resolver = resolver; + } + + /** + * Resolves the resource group ID from CQN keys. Checks for an explicit resource-group reference + * in the keys before falling back to tenant-based resolution via the {@link DeploymentResolver}. + */ + protected String resolveResourceGroup(EventContext context, Map keys) { + if (keys.containsKey("resourceGroup_resourceGroupId")) { + return (String) keys.get("resourceGroup_resourceGroupId"); + } + Object rgObj = keys.get("resourceGroup"); + if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { + return (String) rgMap.get("resourceGroupId"); + } + return resolver.resolveResourceGroup(context.getUserInfo().getTenant()); + } + + /** + * Validates that the given resource group is accessible by the current tenant. Provider/system + * users may access any resource group. In single-tenancy mode, no restriction is applied. Throws + * 404 if the resource group does not belong to the current tenant. + */ + protected void ensureResourceGroupAccessible(EventContext context, String resourceGroupId) { + if (isProviderUser(context) || !config.multiTenancyEnabled()) { + return; + } + String currentTenant = context.getUserInfo().getTenant(); + if (currentTenant == null) { + return; + } + BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId); + if (rg.getLabels() != null + && rg.getLabels().stream() + .anyMatch( + l -> + AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey()) + && currentTenant.equals(l.getValue()))) { + return; + } + throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource not found"); + } + + /** + * Returns whether the current request user is a system/provider user (bypasses tenant checks). + */ + protected static boolean isProviderUser(EventContext context) { + UserInfo userInfo = context.getUserInfo(); + return userInfo.isSystemUser() || userInfo.isInternalUser(); + } + + protected static Map merge(Map keys, Map values) { + Map merged = new HashMap<>(values); + keys.forEach( + (k, v) -> { + if (v != null) merged.put(k, v); + }); + return merged; + } + + protected static List mapResources(List resources, Function mapper) { + if (resources == null) return List.of(); + return resources.stream().map(mapper).toList(); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java new file mode 100644 index 0000000..f2ea8ef --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -0,0 +1,45 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.DeploymentsStopContext; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(AICore_.CDS_NAME) +public class ActionHandler extends AbstractCrudHandler { + + private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); + + public ActionHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); + } + + @On(entity = Deployments_.CDS_NAME) + public void onStop(DeploymentsStopContext context) { + CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel()); + Map keys = analyzer.analyze(context.getCqn()).targetKeys(); + + String deploymentId = (String) keys.get(Deployments.ID); + String resourceGroupId = resolveResourceGroup(context, keys); + + AiDeploymentModificationRequest modRequest = + AiDeploymentModificationRequest.create().targetStatus(AiDeploymentTargetStatus.STOPPED); + clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest); + logger.debug("Stopped deployment {} in resource group {}", deploymentId, resourceGroupId); + context.setCompleted(); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java new file mode 100644 index 0000000..01a9f4b --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -0,0 +1,146 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.model.AiConfiguration; +import com.sap.ai.sdk.core.model.AiConfigurationBaseData; +import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ArtifactArgumentBinding; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBinding; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBindingList; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(AICore_.CDS_NAME) +public class ConfigurationHandler extends AbstractCrudHandler { + + private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); + + public ConfigurationHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); + } + + @On(entity = Configurations_.CDS_NAME) + public void onRead(CdsReadEventContext context) { + CqnSelect select = context.getCqn(); + CdsModel model = context.getModel(); + AnalysisResult analysis = CqnAnalyzer.create(model).analyze(select); + Map keys = analysis.targetKeys(); + Map values = analysis.targetValues(); + + String resourceGroupId = resolveResourceGroup(context, merge(keys, values)); + ensureResourceGroupAccessible(context, resourceGroupId); + logger.debug( + "Reading configurations for resourceGroup={}, keys={}, values={}", + resourceGroupId, + keys, + values); + + String id = (String) keys.get(Configurations.ID); + if (id != null) { + AiConfiguration config = clients.configurationApi().get(resourceGroupId, id); + context.setResult(List.of(toConfigurations(config, resourceGroupId))); + } else { + String scenarioId = (String) values.get(Configurations.SCENARIO_ID); + AiConfigurationList result = + clients + .configurationApi() + .query(resourceGroupId, scenarioId, null, null, null, null, null, null); + List> results = + mapResources(result.getResources(), c -> toConfigurations(c, resourceGroupId)); + logger.debug("ConfigurationApi.query returned {} resources", results.size()); + context.setResult(results); + } + } + + @On + public void onCreate(CdsCreateEventContext context, List entries) { + List> results = new ArrayList<>(); + + for (Configurations entry : entries) { + String resourceGroupId = resolveResourceGroup(context, entry); + ensureResourceGroupAccessible(context, resourceGroupId); + + AiConfigurationBaseData request = + AiConfigurationBaseData.create() + .name(entry.getName()) + .executableId(entry.getExecutableId()) + .scenarioId(entry.getScenarioId()); + + Collection paramBindings = entry.getParameterBindings(); + if (paramBindings != null) { + List sdkBindings = + paramBindings.stream() + .map(p -> AiParameterArgumentBinding.create().key(p.getKey()).value(p.getValue())) + .toList(); + request.parameterBindings(sdkBindings); + } + + var response = clients.configurationApi().create(resourceGroupId, request); + entry.setId(response.getId()); + results.add(entry); + logger.debug( + "Created configuration {} in resource group {}", response.getId(), resourceGroupId); + } + context.setResult(results); + } + + private Configurations toConfigurations(AiConfiguration config, String resourceGroupId) { + Configurations data = Configurations.create(); + data.setId(config.getId()); + data.setName(config.getName()); + data.setExecutableId(config.getExecutableId()); + data.setScenarioId(config.getScenarioId()); + data.put(Configurations.CREATED_AT, config.getCreatedAt()); + if (config.getParameterBindings() != null) { + List bindings = + config.getParameterBindings().stream() + .map( + b -> { + ParameterArgumentBinding bm = ParameterArgumentBinding.create(); + bm.setKey(b.getKey()); + bm.setValue(b.getValue()); + return bm; + }) + .toList(); + data.put(Configurations.PARAMETER_BINDINGS, bindings); + } + if (config.getInputArtifactBindings() != null) { + List bindings = + config.getInputArtifactBindings().stream() + .map( + b -> { + ArtifactArgumentBinding bm = ArtifactArgumentBinding.create(); + bm.setKey(b.getKey()); + bm.setArtifactId(b.getArtifactId()); + return bm; + }) + .toList(); + data.put(Configurations.INPUT_ARTIFACT_BINDINGS, bindings); + } + data.setResourceGroup(ResourceGroups.create(resourceGroupId)); + return data; + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java new file mode 100644 index 0000000..c16f166 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -0,0 +1,245 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.model.AiDeployment; +import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentList; +import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; +import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnDelete; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsDeleteEventContext; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(AICore_.CDS_NAME) +public class DeploymentHandler extends AbstractCrudHandler { + + private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); + + public DeploymentHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); + } + + @On(entity = Deployments_.CDS_NAME) + public void onRead(CdsReadEventContext context) { + CqnSelect select = context.getCqn(); + CdsModel model = context.getModel(); + AnalysisResult analysis = CqnAnalyzer.create(model).analyze(select); + Map keys = analysis.targetKeys(); + Map values = analysis.targetValues(); + + String resourceGroupId = resolveResourceGroup(context, merge(keys, values)); + ensureResourceGroupAccessible(context, resourceGroupId); + + String id = (String) keys.get(Deployments.ID); + if (id != null) { + AiDeploymentResponseWithDetails deployment = clients.deploymentApi().get(resourceGroupId, id); + context.setResult(List.of(toDeployments(deployment, resourceGroupId))); + } else { + AiDeploymentList result = + clients.deploymentApi().query(resourceGroupId, null, null, null, null, null, null, null); + context.setResult( + mapResources(result.getResources(), d -> toDeployments(d, resourceGroupId))); + } + } + + @On + public void onCreate(CdsCreateEventContext context, List entries) { + List> results = new ArrayList<>(); + + for (Deployments entry : entries) { + String resourceGroupId = resolveResourceGroup(context, entry); + ensureResourceGroupAccessible(context, resourceGroupId); + String configurationId = entry.getConfigurationId(); + + AiDeploymentCreationRequest request = + AiDeploymentCreationRequest.create().configurationId(configurationId); + + if (entry.getTtl() != null) { + request.ttl(entry.getTtl()); + } + + var response = clients.deploymentApi().create(resourceGroupId, request); + entry.setId(response.getId()); + entry.setStatus(response.getStatus().getValue()); + results.add(entry); + logger.debug("Created deployment {} in resource group {}", response.getId(), resourceGroupId); + } + context.setResult(results); + } + + @On + public void onUpdate(CdsUpdateEventContext context, List entries) { + if (entries.isEmpty()) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); + } + Deployments data = entries.get(0); + if (data.getTargetStatus() == null && data.getConfigurationId() == null) { + throw new ServiceException( + ErrorStatuses.BAD_REQUEST, + "Update payload must contain 'targetStatus' or 'configurationId'"); + } + + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(context.getCqn()).targetKeys(); + + String deploymentId = (String) keys.get(Deployments.ID); + String resourceGroupId = resolveResourceGroup(context, merge(keys, data)); + ensureResourceGroupAccessible(context, resourceGroupId); + + AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); + + if (data.getTargetStatus() != null) { + modRequest.targetStatus(AiDeploymentTargetStatus.fromValue(data.getTargetStatus())); + } + if (data.getConfigurationId() != null) { + modRequest.configurationId(data.getConfigurationId()); + } + + clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest); + logger.debug("Updated deployment {} in resource group {}", deploymentId, resourceGroupId); + context.setResult(List.of(data)); + } + + @On(entity = Deployments_.CDS_NAME) + public void onDelete(CdsDeleteEventContext context) { + CqnDelete delete = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(delete).targetKeys(); + + String deploymentId = (String) keys.get(Deployments.ID); + String resourceGroupId = resolveResourceGroup(context, keys); + ensureResourceGroupAccessible(context, resourceGroupId); + + clients.deploymentApi().delete(resourceGroupId, deploymentId); + logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId); + context.setResult(List.of()); + } + + // The two AI SDK input types AiDeploymentResponseWithDetails and AiDeployment have identical + // accessor signatures but share no common interface, so we extract values into a unified + // intermediate record (DeploymentValues) before applying them to the generated Deployments + // target via applyTo(). The set-on-target logic is shared; the extraction below is the + // unavoidable structural duplicate that CPD picks up. getStatus()/getTargetStatus() are + // declared @Nonnull by the SDK; getLastOperation() is @Nullable and explicitly null-checked. + + // CPD-OFF + private static Deployments toDeployments( + AiDeploymentResponseWithDetails d, String resourceGroupId) { + return applyTo( + new DeploymentValues( + d.getId(), + d.getDeploymentUrl(), + d.getConfigurationId(), + d.getConfigurationName(), + d.getExecutableId(), + d.getScenarioId(), + d.getStatus().getValue(), + d.getStatusMessage(), + d.getTargetStatus().getValue(), + d.getLastOperation() != null ? d.getLastOperation().getValue() : null, + d.getLatestRunningConfigurationId(), + d.getTtl(), + d.getCreatedAt(), + d.getModifiedAt(), + d.getSubmissionTime(), + d.getStartTime(), + d.getCompletionTime()), + resourceGroupId); + } + + private static Deployments toDeployments(AiDeployment d, String resourceGroupId) { + return applyTo( + new DeploymentValues( + d.getId(), + d.getDeploymentUrl(), + d.getConfigurationId(), + d.getConfigurationName(), + d.getExecutableId(), + d.getScenarioId(), + d.getStatus().getValue(), + d.getStatusMessage(), + d.getTargetStatus().getValue(), + d.getLastOperation() != null ? d.getLastOperation().getValue() : null, + d.getLatestRunningConfigurationId(), + d.getTtl(), + d.getCreatedAt(), + d.getModifiedAt(), + d.getSubmissionTime(), + d.getStartTime(), + d.getCompletionTime()), + resourceGroupId); + } + + // CPD-ON + + private static Deployments applyTo(DeploymentValues v, String resourceGroupId) { + Deployments data = Deployments.create(); + data.setId(v.id); + data.setDeploymentUrl(v.deploymentUrl); + data.setConfigurationId(v.configurationId); + data.setConfigurationName(v.configurationName); + data.setExecutableId(v.executableId); + data.setScenarioId(v.scenarioId); + data.setStatus(v.status); + data.setStatusMessage(v.statusMessage); + data.setTargetStatus(v.targetStatus); + data.setLastOperation(v.lastOperation); + data.setLatestRunningConfigurationId(v.latestRunningConfigurationId); + data.setTtl(v.ttl); + data.put(Deployments.CREATED_AT, v.createdAt); + data.put(Deployments.MODIFIED_AT, v.modifiedAt); + data.put(Deployments.SUBMISSION_TIME, v.submissionTime); + data.put(Deployments.START_TIME, v.startTime); + data.put(Deployments.COMPLETION_TIME, v.completionTime); + data.setResourceGroup(ResourceGroups.create(resourceGroupId)); + return data; + } + + private record DeploymentValues( + String id, + String deploymentUrl, + String configurationId, + String configurationName, + String executableId, + String scenarioId, + String status, + String statusMessage, + String targetStatus, + String lastOperation, + String latestRunningConfigurationId, + String ttl, + OffsetDateTime createdAt, + OffsetDateTime modifiedAt, + OffsetDateTime submissionTime, + OffsetDateTime startTime, + OffsetDateTime completionTime) {} +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java new file mode 100644 index 0000000..d20ba34 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -0,0 +1,94 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Mock ON handler for the AI Core service API events when no AI Core binding is available. Uses + * in-memory maps instead of real API calls. + */ +@ServiceName(AICore_.CDS_NAME) +public class MockAICoreApiHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(MockAICoreApiHandler.class); + + private final AICoreConfig config; + private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); + private final Map deploymentCache = new ConcurrentHashMap<>(); + + public MockAICoreApiHandler(AICoreConfig config) { + this.config = config; + } + + @On + public void onResourceGroup(ResourceGroupContext context) { + String tenantId = context.getTenantId(); + if (tenantId == null) { + tenantId = context.getUserInfo().getTenant(); + } + if (!config.multiTenancyEnabled() || tenantId == null) { + context.setResult(config.defaultResourceGroup()); + return; + } + context.setResult(resolveResourceGroup(tenantId)); + } + + @On + public void onDeploymentId(DeploymentIdContext context) { + String resourceGroupId = context.getResourceGroupId(); + ModelDeploymentSpec spec = context.getSpec(); + String key = resourceGroupId + "::" + spec.configurationName(); + String result = deploymentCache.computeIfAbsent(key, k -> "mock-deployment-" + k); + context.setResult(result); + } + + @On + public void onInferenceClient(InferenceClientContext context) { + throw new ServiceException( + ErrorStatuses.NOT_IMPLEMENTED, + "Inference client is not available without an AI Core service binding"); + } + + /** + * Resolves (or creates) the resource group name for the given tenant using the configured prefix. + */ + public String resolveResourceGroup(String tenantId) { + return tenantResourceGroupCache.computeIfAbsent( + tenantId, id -> config.resourceGroupPrefix() + id); + } + + /** Returns the mock tenant cache for test inspection. */ + public Map getTenantResourceGroupCache() { + return tenantResourceGroupCache; + } + + /** Returns the mock deployment cache for test inspection. */ + public Map getDeploymentCache() { + return deploymentCache; + } + + /** Evicts all entries for the given tenant. */ + public void clearTenantCache(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.remove(tenantId); + if (resourceGroupId != null) { + String prefix = resourceGroupId + "::"; + deploymentCache.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java new file mode 100644 index 0000000..5c8e891 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.DeploymentService; +import com.sap.cds.services.mt.SubscribeEventContext; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(DeploymentService.DEFAULT_NAME) +public class MockAICoreSetupHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(MockAICoreSetupHandler.class); + + private final MockAICoreApiHandler mockHandler; + + public MockAICoreSetupHandler(MockAICoreApiHandler mockHandler) { + this.mockHandler = mockHandler; + } + + @After + @HandlerOrder(HandlerOrder.LATE) + public void afterSubscribe(SubscribeEventContext context) { + String tenantId = context.getTenant(); + // Trigger resource group creation in mock cache + mockHandler.resolveResourceGroup(tenantId); + logger.info("Mock created in-memory resource group for tenant {}", tenantId); + } + + @Before + @HandlerOrder(HandlerOrder.EARLY) + public void beforeUnsubscribe(UnsubscribeEventContext context) { + String tenantId = context.getTenant(); + mockHandler.clearTenantCache(tenantId); + logger.info("Mock cleared in-memory caches for tenant {}", tenantId); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java new file mode 100644 index 0000000..a8ea0c9 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java @@ -0,0 +1,200 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnDelete; +import com.sap.cds.ql.cqn.CqnInsert; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsDeleteEventContext; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@ServiceName(AICore_.CDS_NAME) +public class MockEntityHandler implements EventHandler { + + private final Map> resourceGroups = new ConcurrentHashMap<>(); + private final Map> deployments = new ConcurrentHashMap<>(); + private final Map> configurations = new ConcurrentHashMap<>(); + + // --- Resource Groups --- + + @On(entity = ResourceGroups_.CDS_NAME) + public void readResourceGroups(CdsReadEventContext context) { + CqnSelect select = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + AnalysisResult analysis = analyzer.analyze(select); + Map keys = analysis.targetKeys(); + + String id = (String) keys.get("resourceGroupId"); + if (id != null) { + Map rg = resourceGroups.get(id); + context.setResult(rg != null ? List.of(rg) : List.of()); + } else { + Map values = analysis.targetValues(); + String tenantId = (String) values.get("tenantId"); + if (tenantId != null) { + List> filtered = + resourceGroups.values().stream() + .filter(rg -> tenantId.equals(rg.get("tenantId"))) + .toList(); + context.setResult(filtered); + } else { + context.setResult(List.copyOf(resourceGroups.values())); + } + } + } + + @On(entity = ResourceGroups_.CDS_NAME) + public void createResourceGroups(CdsCreateEventContext context) { + CqnInsert insert = context.getCqn(); + List> results = new ArrayList<>(); + for (Map entry : insert.entries()) { + String id = (String) entry.getOrDefault("resourceGroupId", UUID.randomUUID().toString()); + CdsData stored = CdsData.create(entry); + stored.put("resourceGroupId", id); + stored.put("status", "PROVISIONED"); + resourceGroups.put(id, stored); + results.add(stored); + } + context.setResult(results); + } + + @On(entity = ResourceGroups_.CDS_NAME) + public void updateResourceGroups(CdsUpdateEventContext context) { + CqnUpdate update = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(update).targetKeys(); + String id = (String) keys.get("resourceGroupId"); + Map existing = resourceGroups.getOrDefault(id, CdsData.create()); + for (Map entry : update.entries()) { + existing.putAll(entry); + } + existing.put("resourceGroupId", id); + resourceGroups.put(id, existing); + context.setResult(List.of(CdsData.create(existing))); + } + + @On(entity = ResourceGroups_.CDS_NAME) + public void deleteResourceGroups(CdsDeleteEventContext context) { + CqnDelete delete = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(delete).targetKeys(); + String id = (String) keys.get("resourceGroupId"); + resourceGroups.remove(id); + context.setResult(List.of()); + } + + // --- Deployments --- + + @On(entity = Deployments_.CDS_NAME) + public void readDeployments(CdsReadEventContext context) { + CqnSelect select = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(select).targetKeys(); + + String id = (String) keys.get("id"); + if (id != null) { + Map d = deployments.get(id); + context.setResult(d != null ? List.of(d) : List.of()); + } else { + context.setResult(List.copyOf(deployments.values())); + } + } + + @On(entity = Deployments_.CDS_NAME) + public void createDeployments(CdsCreateEventContext context) { + CqnInsert insert = context.getCqn(); + List> results = new ArrayList<>(); + for (Map entry : insert.entries()) { + String id = (String) entry.getOrDefault("id", UUID.randomUUID().toString()); + CdsData stored = CdsData.create(entry); + stored.put("id", id); + stored.put("status", "RUNNING"); + deployments.put(id, stored); + results.add(stored); + } + context.setResult(results); + } + + @On(entity = Deployments_.CDS_NAME) + public void updateDeployments(CdsUpdateEventContext context) { + CqnUpdate update = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(update).targetKeys(); + String id = (String) keys.get("id"); + Map existing = deployments.getOrDefault(id, CdsData.create()); + for (Map entry : update.entries()) { + existing.putAll(entry); + } + existing.put("id", id); + deployments.put(id, existing); + context.setResult(List.of(CdsData.create(existing))); + } + + @On(entity = Deployments_.CDS_NAME) + public void deleteDeployments(CdsDeleteEventContext context) { + CqnDelete delete = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(delete).targetKeys(); + String id = (String) keys.get("id"); + deployments.remove(id); + context.setResult(List.of()); + } + + // --- Configurations --- + + @On(entity = Configurations_.CDS_NAME) + public void readConfigurations(CdsReadEventContext context) { + CqnSelect select = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(select).targetKeys(); + + String id = (String) keys.get("id"); + if (id != null) { + Map c = configurations.get(id); + context.setResult(c != null ? List.of(c) : List.of()); + } else { + context.setResult(List.copyOf(configurations.values())); + } + } + + @On(entity = Configurations_.CDS_NAME) + public void createConfigurations(CdsCreateEventContext context) { + CqnInsert insert = context.getCqn(); + List> results = new ArrayList<>(); + for (Map entry : insert.entries()) { + String id = (String) entry.getOrDefault("id", UUID.randomUUID().toString()); + CdsData stored = CdsData.create(entry); + stored.put("id", id); + configurations.put(id, stored); + results.add(stored); + } + context.setResult(results); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java new file mode 100644 index 0000000..567f292 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -0,0 +1,244 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; +import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; +import com.sap.cds.CdsData; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnDelete; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsDeleteEventContext; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(AICore_.CDS_NAME) +public class ResourceGroupHandler extends AbstractCrudHandler { + + private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); + + public ResourceGroupHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); + } + + @On(entity = ResourceGroups_.CDS_NAME) + public void onRead(CdsReadEventContext context) { + CqnSelect select = context.getCqn(); + CdsModel model = context.getModel(); + AnalysisResult analysis = CqnAnalyzer.create(model).analyze(select); + + Map keys = analysis.targetKeys(); + Map values = analysis.targetValues(); + + String resourceGroupId = (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); + if (resourceGroupId == null) { + resourceGroupId = (String) values.get(ResourceGroups.RESOURCE_GROUP_ID); + } + + if (resourceGroupId != null) { + BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId); + ensureOwnedByCurrentTenant(context, rg); + context.setResult(List.of(toMap(rg))); + } else { + List labelSelector = buildTenantLabelSelector(context, values); + BckndResourceGroupList result = + clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); + context.setResult(mapResources(result.getResources(), this::toMap)); + } + } + + @On(entity = ResourceGroups_.CDS_NAME) + public void onCreate(CdsCreateEventContext context, List entries) { + List> results = new ArrayList<>(); + + for (ResourceGroups entry : entries) { + String resourceGroupId = entry.getResourceGroupId(); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create().resourceGroupId(resourceGroupId); + + @SuppressWarnings("unchecked") + List> labels = + (List>) entry.get(ResourceGroups.LABELS); + List mergedLabels = new ArrayList<>(); + + // User-supplied labels take precedence: if they include the tenant label key, we skip + // the auto-generated one based on the tenantId field. + boolean userSuppliedTenantLabel = + labels != null + && labels.stream().anyMatch(l -> AICoreConfig.TENANT_LABEL_KEY.equals(l.get("key"))); + + if (entry.getTenantId() != null && !userSuppliedTenantLabel) { + mergedLabels.add( + BckndResourceGroupLabel.create() + .key(AICoreConfig.TENANT_LABEL_KEY) + .value(entry.getTenantId())); + } + + if (labels != null) { + mergedLabels.addAll(toSdkLabels(labels)); + } + + if (!mergedLabels.isEmpty()) { + request.labels(mergedLabels); + } + + clients.resourceGroupApi().create(request); + logger.debug("Created resource group {}", resourceGroupId); + results.add(entry); + } + context.setResult(results); + } + + @On(entity = ResourceGroups_.CDS_NAME) + public void onUpdate(CdsUpdateEventContext context) { + CqnUpdate update = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(update).targetKeys(); + + String resourceGroupId = resolveResourceGroupId(context, keys); + ensureOwnedByCurrentTenant(context, clients.resourceGroupApi().get(resourceGroupId)); + + Map data = update.entries().get(0); + BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); + + @SuppressWarnings("unchecked") + List> labels = (List>) data.get(ResourceGroups.LABELS); + if (labels != null) { + patchRequest.labels(toSdkLabels(labels)); + } + + clients.resourceGroupApi().patch(resourceGroupId, patchRequest); + logger.debug("Updated resource group {}", resourceGroupId); + context.setResult(List.of(CdsData.create(data))); + } + + @On(entity = ResourceGroups_.CDS_NAME) + public void onDelete(CdsDeleteEventContext context) { + CqnDelete delete = context.getCqn(); + CdsModel model = context.getModel(); + CqnAnalyzer analyzer = CqnAnalyzer.create(model); + Map keys = analyzer.analyze(delete).targetKeys(); + + String resourceGroupId = resolveResourceGroupId(context, keys); + ensureOwnedByCurrentTenant(context, clients.resourceGroupApi().get(resourceGroupId)); + + clients.resourceGroupApi().delete(resourceGroupId); + logger.debug("Deleted resource group {}", resourceGroupId); + context.setResult(List.of()); + } + + private String resolveResourceGroupId(EventContext context, Map keys) { + if (keys.containsKey(ResourceGroups.RESOURCE_GROUP_ID)) { + return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); + } + if (keys.containsKey(ResourceGroups.TENANT_ID)) { + return resolver.resolveResourceGroup((String) keys.get(ResourceGroups.TENANT_ID)); + } + return resolver.resolveResourceGroup(context.getUserInfo().getTenant()); + } + + /** + * Builds a tenant-scoped label selector for list queries. In multi-tenancy mode, non-provider + * users are restricted to their own tenant's resource groups. + */ + private List buildTenantLabelSelector(EventContext context, Map values) { + // If a specific tenantId is requested in the query, use that + if (values.containsKey(ResourceGroups.TENANT_ID)) { + String tenantId = (String) values.get(ResourceGroups.TENANT_ID); + return List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); + } + // In MT mode, restrict non-provider users to their own tenant + if (config.multiTenancyEnabled() && !isProviderUser(context)) { + String currentTenant = context.getUserInfo().getTenant(); + if (currentTenant != null) { + return List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + currentTenant); + } + } + return null; + } + + /** + * Verifies that the given resource group is owned by the current tenant. Provider/system users + * are allowed to access any resource group. Throws 404 if the resource group belongs to a + * different tenant. + */ + private void ensureOwnedByCurrentTenant(EventContext context, BckndResourceGroup rg) { + if (isProviderUser(context)) { + return; + } + if (!config.multiTenancyEnabled()) { + return; + } + String currentTenant = context.getUserInfo().getTenant(); + if (currentTenant == null) { + return; + } + if (rg.getLabels() != null + && rg.getLabels().stream() + .anyMatch( + l -> + AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey()) + && currentTenant.equals(l.getValue()))) { + return; + } + throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource group not found"); + } + + private static List toSdkLabels(List> labels) { + return labels.stream() + .map( + l -> + BckndResourceGroupLabel.create() + .key((String) l.get("key")) + .value((String) l.get("value"))) + .toList(); + } + + private ResourceGroups toMap(BckndResourceGroup rg) { + ResourceGroups data = ResourceGroups.create(); + data.setResourceGroupId(rg.getResourceGroupId()); + data.setStatus(rg.getStatus().getValue()); + data.setStatusMessage(rg.getStatusMessage()); + data.put(ResourceGroups.CREATED_AT, rg.getCreatedAt()); + if (rg.getLabels() != null) { + List labels = new ArrayList<>(rg.getLabels().size()); + for (BckndResourceGroupLabel l : rg.getLabels()) { + var lm = com.sap.cds.feature.aicore.generated.cds4j.aicore.BckndResourceGroupLabel.create(); + lm.setKey(l.getKey()); + lm.setValue(l.getValue()); + labels.add(lm); + if (AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey())) { + data.setTenantId(l.getValue()); + } + } + data.put(ResourceGroups.LABELS, labels); + } + return data; + } +} diff --git a/cds-feature-ai-core/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/cds-feature-ai-core/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..4570890 --- /dev/null +++ b/cds-feature-ai-core/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.feature.aicore.core.AICoreServiceConfiguration diff --git a/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds b/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds new file mode 100644 index 0000000..d823f49 --- /dev/null +++ b/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds @@ -0,0 +1,136 @@ +@protocol: 'none' +service AICore { + + @cds.persistence.skip + entity resourceGroups { + key resourceGroupId : String; + tenantId : String; + zoneId : String; + @readonly + createdAt : Timestamp; + labels : BckndResourceGroupLabels; + @assert.range: true + @readonly + status : String enum { + PROVISIONED; + ERROR; + PROVISIONING; + }; + statusMessage : String; + servicePlan : String; + }; + + @cds.persistence.skip + entity deployments { + @assert.format: '^[\w.-]{4,64}$' + key id : String; + deploymentUrl : String; + @mandatory: true + @assert.format: '^[\w.-]{4,64}$' + configurationId : String; + @assert.format: '^[\w\s.!?,;:\[\](){}<>"''=+*/\\^&%@~$#|-]*$' + configurationName : String(256); + @assert.format: '^[\w.-]{4,64}$' + executableId : String; + @assert.format: '^[\w.-]{4,64}$' + scenarioId : String; + @readonly + status : String enum { + PENDING; + RUNNING; + COMPLETED; + DEAD; + STOPPING; + STOPPED; + UNKNOWN; + }; + statusMessage : String(256); + @assert.range: true + targetStatus : String enum { + running; + STOPPED; + deleted; + }; + lastOperation : String; + @assert.format: '^[\w.-]{4,64}$' + latestRunningConfigurationId : String; + @assert.format: '^[0-9]+[m,M,h,H,d,D]$' + ttl : String; + details : AiDeploymentDetails; + @readonly + createdAt : Timestamp; + modifiedAt : Timestamp; + submissionTime : Timestamp; + startTime : Timestamp; + completionTime : Timestamp; + resourceGroup : Association to one resourceGroups + on 1 = 1; + } actions { + action stop(); + }; + + @cds.persistence.skip + entity configurations { + @mandatory: true + @assert.format: '^[\w\s.!?,;:\[\](){}<>"''=+*/\\^&%@~$#|-]*$' + name : String(256); + @mandatory: true + @assert.format: '^[\w.-]{4,64}$' + executableId : String; + @mandatory: true + @assert.format: '^[\w.-]{4,64}$' + scenarioId : String; + parameterBindings : ParameterArgumentBindingList; + inputArtifactBindings : ArtifactArgumentBindingList; + @assert.format: '^[\w.-]{4,64}$' + key id : String; + @readonly + createdAt : Timestamp; + resourceGroup : Association to one resourceGroups + on 1 = 1; + }; + + type BckndResourceGroupLabels : many BckndResourceGroupLabel; + + type BckndResourceGroupLabel { + @mandatory: true + ![key] : String(63); + @mandatory: true + value : String(5000); + }; + + type AiBackendDetails {}; + + type AiScalingDetails { + backendDetails : AiBackendDetails; + }; + + type AiResourcesDetails { + backendDetails : AiBackendDetails; + }; + + type AiDeploymentDetails { + scaling : AiScalingDetails; + resources : AiResourcesDetails; + }; + + type ParameterArgumentBinding { + @mandatory: true + ![key] : String(256); + @mandatory: true + value : String(5000); + }; + + type ParameterArgumentBindingList : many ParameterArgumentBinding; + + type ArtifactArgumentBinding { + @mandatory: true + ![key] : String(256); + @mandatory: true + @assert.format: '^[\w.-]{4,64}$' + artifactId : String; + }; + + type ArtifactArgumentBindingList : many ArtifactArgumentBinding; + +} diff --git a/cds-feature-ai-core/src/main/resources/spotbugs-exclusion-filter.xml b/cds-feature-ai-core/src/main/resources/spotbugs-exclusion-filter.xml new file mode 100644 index 0000000..ee4e277 --- /dev/null +++ b/cds-feature-ai-core/src/main/resources/spotbugs-exclusion-filter.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java new file mode 100644 index 0000000..fcea483 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -0,0 +1,78 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AICoreServiceConfiguration} using a real {@link CdsRuntime} booted with the AICore + * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy + * Mockito mocks. + * + *

Since the test runtime has no service bindings, the configuration always registers mock + * handlers regardless of environment variables. + */ +class AICoreServiceConfigurationTest { + + @Test + void noBinding_noMultiTenancy_registersService() { + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .environmentConfigurations() + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + + assertThat(service).isNotNull(); + } + + @Test + void noBinding_withSidecarUrl_registersService() { + CdsProperties props = new CdsProperties(); + CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); + CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); + sidecar.setUrl("http://localhost:4004"); + mt.setSidecar(sidecar); + props.setMultiTenancy(mt); + + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .environmentConfigurations() + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + + assertThat(service).isNotNull(); + } + + @Test + void noModel_skipsServiceRegistration() { + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + + assertThat(service).isNull(); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java new file mode 100644 index 0000000..e43f64b --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -0,0 +1,283 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.ai.sdk.core.model.AiDeployment; +import com.sap.ai.sdk.core.model.AiDeploymentCreationResponse; +import com.sap.ai.sdk.core.model.AiDeploymentList; +import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; +import com.sap.ai.sdk.core.model.AiDeploymentStatus; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the happy paths of deployment ID resolution: cache hit on a RUNNING deployment, + * stale-cache invalidation when the cached deployment is gone, and reuse of an existing matching + * deployment found via query. + */ +class AICoreServiceImplDeploymentIdTest { + + private static final String RG = "rg-1"; + private static final String CONFIG_NAME = "rpt1-config"; + private static final String SCENARIO = "foundation-models"; + private static final String DEPLOYMENT_ID = "dep-123"; + + private DeploymentApi deploymentApi; + private ConfigurationApi configurationApi; + private ResourceGroupApi resourceGroupApi; + private RemoteService service; + private DeploymentResolver resolver; + + private final ModelDeploymentSpec spec = + new ModelDeploymentSpec(SCENARIO, "exec", CONFIG_NAME, List.of(), d -> true); + + private String cacheKey() { + return DeploymentResolver.deploymentCacheKey(RG, spec); + } + + /** + * Creates a {@link RemoteService} properly registered with a CDS runtime and the {@link + * AICoreApiHandler} so that {@code emit()} dispatches to the handler. + */ + private RemoteService createService(boolean multiTenancy) { + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore_.CDS_NAME); + rsConfig.setModel(AICore_.CDS_NAME); + props.getRemote().getServices().put(AICore_.CDS_NAME, rsConfig); + + CdsRuntimeConfigurer configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + CdsRuntime runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 1, 1L, multiTenancy); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.complete(); + + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + @BeforeEach + void setUp() { + deploymentApi = mock(DeploymentApi.class); + configurationApi = mock(ConfigurationApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + service = createService(false); + } + + @Test + void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() throws Exception { + putInDeploymentCache(resolver, cacheKey(), DEPLOYMENT_ID); + + AiDeploymentResponseWithDetails running = mock(AiDeploymentResponseWithDetails.class); + when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); + when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); + + String result = emitDeploymentId(service, RG, spec); + + assertThat(result).isEqualTo(DEPLOYMENT_ID); + verify(deploymentApi).get(RG, DEPLOYMENT_ID); + verify(deploymentApi, never()).query(any(), any(), any(), any(), any(), any(), any(), any()); + verify(deploymentApi, never()).create(any(), any()); + } + + @Test + void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() throws Exception { + String otherDeployment = "dep-456"; + putInDeploymentCache(resolver, cacheKey(), "stale-id"); + + OpenApiRequestException notFound = new OpenApiRequestException("gone").statusCode(404); + when(deploymentApi.get(RG, "stale-id")).thenThrow(notFound); + + AiDeployment existing = mock(AiDeployment.class); + when(existing.getId()).thenReturn(otherDeployment); + when(existing.getConfigurationName()).thenReturn(CONFIG_NAME); + when(existing.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); + AiDeploymentList list = mock(AiDeploymentList.class); + when(list.getResources()).thenReturn(List.of(existing)); + when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) + .thenReturn(list); + + String result = emitDeploymentId(service, RG, spec); + + assertThat(result).isEqualTo(otherDeployment); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), otherDeployment); + verify(deploymentApi, never()).create(any(), any()); + } + + @Test + void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() throws Exception { + putInDeploymentCache(resolver, cacheKey(), "still-valid-id"); + + OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); + when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); + + assertThatThrownBy(() -> emitDeploymentId(service, RG, spec)).rootCause().isSameAs(serverError); + + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), "still-valid-id"); + verify(deploymentApi, never()).query(any(), any(), any(), any(), any(), any(), any(), any()); + verify(deploymentApi, never()).create(any(), any()); + } + + @Test + void noCache_existingMatchingDeployment_isReusedAndCached() throws Exception { + AiDeployment existing = mock(AiDeployment.class); + when(existing.getId()).thenReturn(DEPLOYMENT_ID); + when(existing.getConfigurationName()).thenReturn(CONFIG_NAME); + when(existing.getStatus()).thenReturn(AiDeploymentStatus.PENDING); + AiDeploymentList list = mock(AiDeploymentList.class); + when(list.getResources()).thenReturn(List.of(existing)); + when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) + .thenReturn(list); + + String result = emitDeploymentId(service, RG, spec); + + assertThat(result).isEqualTo(DEPLOYMENT_ID); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); + verify(deploymentApi, never()).create(any(), any()); + verify(deploymentApi, never()).get(any(), any()); + } + + @Test + void secondCallUsesCachedResult_singleQueryToApi() { + AiDeployment existing = mock(AiDeployment.class); + when(existing.getId()).thenReturn(DEPLOYMENT_ID); + when(existing.getConfigurationName()).thenReturn(CONFIG_NAME); + when(existing.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); + AiDeploymentList list = mock(AiDeploymentList.class); + when(list.getResources()).thenReturn(List.of(existing)); + when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) + .thenReturn(list); + + AiDeploymentResponseWithDetails running = mock(AiDeploymentResponseWithDetails.class); + when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); + when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); + + String first = emitDeploymentId(service, RG, spec); + String second = emitDeploymentId(service, RG, spec); + + assertThat(first).isEqualTo(DEPLOYMENT_ID); + assertThat(second).isEqualTo(DEPLOYMENT_ID); + verify(deploymentApi, times(1)) + .query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any()); + verify(deploymentApi, times(1)).get(RG, DEPLOYMENT_ID); + } + + @Test + void resourceGroupForTenant_nullTenantId_returnsDefault() { + RemoteService mtService = createService(true); + + String result = emitResourceGroup(mtService, null); + assertThat(result).isEqualTo("default"); + } + + @Test + void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { + String result = emitResourceGroup(service, "any-tenant"); + assertThat(result).isEqualTo("default"); + } + + @Test + void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws Exception { + AiDeploymentList emptyList = mock(AiDeploymentList.class); + when(emptyList.getResources()).thenReturn(List.of()); + when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) + .thenReturn(emptyList); + + AiConfigurationList configList = mock(AiConfigurationList.class); + var existingConfig = mock(com.sap.ai.sdk.core.model.AiConfiguration.class); + when(existingConfig.getId()).thenReturn("cfg-1"); + when(existingConfig.getName()).thenReturn(CONFIG_NAME); + when(configList.getResources()).thenReturn(List.of(existingConfig)); + when(configurationApi.query(eq(RG), eq(SCENARIO), any(), any(), any(), any(), any(), any())) + .thenReturn(configList); + + AiDeploymentCreationResponse created = mock(AiDeploymentCreationResponse.class); + when(created.getId()).thenReturn(DEPLOYMENT_ID); + when(deploymentApi.create(eq(RG), any())).thenReturn(created); + + AiDeploymentResponseWithDetails runningPoll = mock(AiDeploymentResponseWithDetails.class); + when(runningPoll.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); + when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(runningPoll); + + String result = emitDeploymentId(service, RG, spec); + + assertThat(result).isEqualTo(DEPLOYMENT_ID); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); + verify(configurationApi, never()).create(any(), any()); + verify(deploymentApi).create(eq(RG), any()); + } + + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + private static String emitDeploymentId(RemoteService svc, String rg, ModelDeploymentSpec spec) { + DeploymentIdContext ctx = DeploymentIdContext.create(); + ctx.setResourceGroupId(rg); + ctx.setSpec(spec); + svc.emit(ctx); + return ctx.getResult(); + } + + private static String emitResourceGroup(RemoteService svc, String tenantId) { + ResourceGroupContext ctx = ResourceGroupContext.create(); + ctx.setTenantId(tenantId); + svc.emit(ctx); + return ctx.getResult(); + } + + @SuppressWarnings("unchecked") + private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); + } + + @SuppressWarnings("unchecked") + private static Map getDeploymentCache(DeploymentResolver resolver) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); + field.setAccessible(true); + return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java new file mode 100644 index 0000000..78fb35b --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java @@ -0,0 +1,202 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.lang.reflect.Field; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +class AICoreServiceImplTest { + + private static final AICoreConfig CONFIG = new AICoreConfig("default", "cds-", 10, 300, true); + + @Test + void notReadyYet_topLevel403_returnsTrue() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(403); + + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); + } + + @Test + void notReadyYet_topLevel412_returnsTrue() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(412); + + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); + } + + @Test + void notReadyYet_topLevel404_returnsTrue() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(404); + + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); + } + + @Test + void notReadyYet_topLevel500_returnsFalse() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(500); + + assertThat(DeploymentResolver.notReadyYet(e)).isFalse(); + } + + @Test + void notReadyYet_topLevel500WrappingInner403_returnsTrue() { + OpenApiRequestException inner = mock(OpenApiRequestException.class); + when(inner.statusCode()).thenReturn(403); + + OpenApiRequestException outer = mock(OpenApiRequestException.class); + when(outer.statusCode()).thenReturn(500); + when(outer.getCause()).thenReturn(inner); + + assertThat(DeploymentResolver.notReadyYet(outer)).isTrue(); + } + + @Test + void notReadyYet_nullStatusCodeOnAllLevels_returnsFalse() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(null); + + assertThat(DeploymentResolver.notReadyYet(e)).isFalse(); + } + + @Test + void deploymentLocksFieldIsConcurrentHashMap() throws NoSuchFieldException { + // Locks must live in a non-evicting map: a Caffeine cache could evict an entry between two + // threads' lookups, causing them to synchronize on different objects for the same cache key + // and race to create duplicate AI Core deployments. + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); + assertThat(field.getType()).isEqualTo(ConcurrentHashMap.class); + } + + @Test + void concurrentHashMapComputeIfAbsentReturnsSameLockObjectForSameKey() { + ConcurrentHashMap locks = new ConcurrentHashMap<>(); + + Object lock1 = locks.computeIfAbsent("rg-1", k -> new Object()); + Object lock2 = locks.computeIfAbsent("rg-1", k -> new Object()); + Object differentRg = locks.computeIfAbsent("rg-2", k -> new Object()); + + assertThat(lock1).isSameAs(lock2); + assertThat(lock1).isNotSameAs(differentRg); + } + + @Test + void invalidateTenantRemovesAllRelatedEntries() throws Exception { + String tenantId = "tenant-1"; + String resourceGroupId = "cds-tenant-1"; + + DeploymentResolver resolver = freshResolver(); + putInTenantCache(resolver, tenantId, resourceGroupId); + putInDeploymentCache(resolver, resourceGroupId, "deployment-id"); + putInDeploymentLocks(resolver, resourceGroupId); + + resolver.invalidateTenant(tenantId); + + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(tenantId); + assertThat(getDeploymentCache(resolver)).doesNotContainKey(resourceGroupId); + assertThat(getDeploymentLocks(resolver)).doesNotContainKey(resourceGroupId); + } + + @Test + void invalidateTenantLeavesOtherTenantsUntouched() throws Exception { + String tenantA = "tenant-a"; + String resourceGroupA = "cds-tenant-a"; + String tenantB = "tenant-b"; + String resourceGroupB = "cds-tenant-b"; + + DeploymentResolver resolver = freshResolver(); + putInTenantCache(resolver, tenantA, resourceGroupA); + putInTenantCache(resolver, tenantB, resourceGroupB); + putInDeploymentCache(resolver, resourceGroupA, "deployment-a"); + putInDeploymentCache(resolver, resourceGroupB, "deployment-b"); + putInDeploymentLocks(resolver, resourceGroupA); + putInDeploymentLocks(resolver, resourceGroupB); + + resolver.invalidateTenant(tenantA); + + assertThat(resolver.getTenantResourceGroupCacheView()) + .doesNotContainKey(tenantA) + .containsKey(tenantB); + assertThat(getDeploymentCache(resolver)) + .doesNotContainKey(resourceGroupA) + .containsKey(resourceGroupB); + assertThat(getDeploymentLocks(resolver)) + .doesNotContainKey(resourceGroupA) + .containsKey(resourceGroupB); + } + + @Test + void invalidateTenantIsNoOpForUnknownTenant() throws Exception { + String resourceGroupId = "cds-tenant-1"; + + DeploymentResolver resolver = freshResolver(); + putInDeploymentCache(resolver, resourceGroupId, "deployment-id"); + putInDeploymentLocks(resolver, resourceGroupId); + + resolver.invalidateTenant("unknown-tenant"); + + assertThat(getDeploymentCache(resolver)).containsKey(resourceGroupId); + assertThat(getDeploymentLocks(resolver)).containsKey(resourceGroupId); + } + + private static DeploymentResolver freshResolver() { + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ResourceGroupApi resourceGroupApi = mock(ResourceGroupApi.class); + return new DeploymentResolver(CONFIG, deploymentApi, resourceGroupApi); + } + + @SuppressWarnings("unchecked") + private static void putInTenantCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("tenantResourceGroupCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); + } + + @SuppressWarnings("unchecked") + private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); + } + + @SuppressWarnings("unchecked") + private static java.util.Map getDeploymentCache(DeploymentResolver resolver) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); + field.setAccessible(true); + return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); + } + + private static void putInDeploymentLocks(DeploymentResolver resolver, String key) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap locks = + (ConcurrentHashMap) field.get(resolver); + locks.put(key, new Object()); + } + + @SuppressWarnings("unchecked") + private static ConcurrentHashMap getDeploymentLocks(DeploymentResolver resolver) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); + field.setAccessible(true); + return (ConcurrentHashMap) field.get(resolver); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java new file mode 100644 index 0000000..9233690 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java @@ -0,0 +1,186 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.lang.reflect.Field; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AICoreSetupHandlerTest { + + private static final String TENANT = "tenant-1"; + private static final String RG_ID = "cds-tenant-1"; + + @Mock private ResourceGroupApi resourceGroupApi; + @Mock private UnsubscribeEventContext unsubscribeContext; + + private DeploymentResolver resolver; + private AICoreClients clients; + private AICoreSetupHandler cut; + + @BeforeEach + void setUp() { + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + clients = + new AICoreClients( + deploymentApi, + mock(ConfigurationApi.class), + resourceGroupApi, + mock(AiCoreService.class)); + resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + when(unsubscribeContext.getTenant()).thenReturn(TENANT); + cut = new AICoreSetupHandler(clients, resolver); + } + + @Test + void cacheHit_deletesAndClears() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); + + cut.beforeUnsubscribe(unsubscribeContext); + + verify(resourceGroupApi).delete(RG_ID); + verify(resourceGroupApi, never()).getAll(any(), any(), any(), any(), any(), any(), any()); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); + } + + @Test + void cacheMiss_fallsBackToApiAndDeletes() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn(RG_ID); + BckndResourceGroupList list = listOf(List.of(rg)); + ArgumentCaptor> labelCaptor = labelSelectorCaptor(); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), labelCaptor.capture())) + .thenReturn(list); + + cut.beforeUnsubscribe(unsubscribeContext); + + assertThat(labelCaptor.getValue()) + .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=" + TENANT); + verify(resourceGroupApi).delete(RG_ID); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); + } + + @Test + void cacheMissAndApiReturnsEmpty_isNoOp() { + BckndResourceGroupList empty = listOf(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(empty); + + cut.beforeUnsubscribe(unsubscribeContext); + + verify(resourceGroupApi, never()).delete(any()); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); + } + + @Test + void cacheMissAndApiReturnsNullResources_isNoOp() { + BckndResourceGroupList list = mock(BckndResourceGroupList.class); + when(list.getResources()).thenReturn(null); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())).thenReturn(list); + + cut.beforeUnsubscribe(unsubscribeContext); + + verify(resourceGroupApi, never()).delete(any()); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); + } + + @Test + void deleteReturns404_treatedAsSuccess() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); + OpenApiRequestException notFound = new OpenApiRequestException("not found").statusCode(404); + when(resourceGroupApi.delete(RG_ID)).thenThrow(notFound); + + cut.beforeUnsubscribe(unsubscribeContext); + + verify(resourceGroupApi).delete(RG_ID); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); + } + + @Test + void deleteReturnsOther5xx_propagatesAsServiceException() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); + OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(500); + when(resourceGroupApi.delete(RG_ID)).thenThrow(serverError); + + assertThatThrownBy(() -> cut.beforeUnsubscribe(unsubscribeContext)) + .isInstanceOf(ServiceException.class) + .hasCauseReference(serverError); + // Cache still cleared in finally. + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); + } + + @Test + void unsubscribeTwice_secondCallIsNoOp() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); + + cut.beforeUnsubscribe(unsubscribeContext); + + // Second call: cache empty → fallback → API returns empty → no-op. + BckndResourceGroupList empty = listOf(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(empty); + + cut.beforeUnsubscribe(unsubscribeContext); + + verify(resourceGroupApi, times(1)).delete(RG_ID); + verify(resourceGroupApi, times(1)).getAll(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void getAllThrows_wrappedInServiceException() { + OpenApiRequestException boom = new OpenApiRequestException("boom").statusCode(503); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())).thenThrow(boom); + + assertThatThrownBy(() -> cut.beforeUnsubscribe(unsubscribeContext)) + .isInstanceOf(ServiceException.class) + .hasCauseReference(boom); + verify(resourceGroupApi, never()).delete(any()); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static ArgumentCaptor> labelSelectorCaptor() { + return ArgumentCaptor.forClass((Class) List.class); + } + + private static BckndResourceGroupList listOf(List resources) { + BckndResourceGroupList list = mock(BckndResourceGroupList.class); + when(list.getResources()).thenReturn(resources); + return list; + } + + @SuppressWarnings("unchecked") + private static void putInTenantCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("tenantResourceGroupCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java new file mode 100644 index 0000000..00919b7 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -0,0 +1,119 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link MockAICoreApiHandler} verifying the mock behavior when no AI Core binding is + * present. These tests boot a real CDS runtime with mock handlers to validate end-to-end flow. + */ +class MockAICoreServiceImplTest { + + private RemoteService createMockService(boolean multiTenancy) { + CdsProperties props = new CdsProperties(); + if (multiTenancy) { + CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); + CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); + sidecar.setUrl("http://localhost:4004"); + mt.setSidecar(sidecar); + props.setMultiTenancy(mt); + } + + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .environmentConfigurations() + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + @Test + void noMultiTenancy_resourceGroupReturnsDefault() { + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); + } + + @Test + void noMultiTenancy_resourceGroupForTenant_returnsDefault() { + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); + } + + @Test + void multiTenancy_resourceGroupForTenant_returnsPrefixed() { + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("my-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("cds-my-tenant"); + } + + @Test + void multiTenancy_resourceGroupForTenant_cachesResult() { + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("t1"); + service.emit(rgCtx1); + String first = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("t1"); + service.emit(rgCtx2); + String second = rgCtx2.getResult(); + assertThat(first).isEqualTo(second); + } + + @Test + void deploymentId_returnsMockId() { + RemoteService service = createMockService(false); + var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId("default"); + depCtx.setSpec(spec); + service.emit(depCtx); + String id = depCtx.getResult(); + assertThat(id).startsWith("mock-deployment-"); + } + + @Test + void deploymentId_cachesSameResult() { + RemoteService service = createMockService(false); + var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); + + DeploymentIdContext depCtx1 = DeploymentIdContext.create(); + depCtx1.setResourceGroupId("default"); + depCtx1.setSpec(spec); + service.emit(depCtx1); + String first = depCtx1.getResult(); + + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId("default"); + depCtx2.setSpec(spec); + service.emit(depCtx2); + String second = depCtx2.getResult(); + assertThat(first).isEqualTo(second); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java new file mode 100644 index 0000000..03d360a --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -0,0 +1,164 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiConfiguration; +import com.sap.ai.sdk.core.model.AiConfigurationBaseData; +import com.sap.ai.sdk.core.model.AiConfigurationCreationResponse; +import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Integration-style tests for {@link ConfigurationHandler} using a real CDS runtime. Only the SDK + * API clients are mocked since they talk to a remote AI Core service. + */ +class ConfigurationHandlerTest { + + private static CdsRuntime runtime; + private static RemoteService service; + private static ConfigurationApi configurationApi; + private static ResourceGroupApi resourceGroupApi; + + @BeforeAll + static void bootRuntime() { + configurationApi = mock(ConfigurationApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); + configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + @BeforeEach + void clearMockInvocations() { + clearInvocations(configurationApi, resourceGroupApi); + } + + @Test + void onRead_returnsConfigurationsForResourceGroup() { + AiConfiguration cfg = mock(AiConfiguration.class); + when(cfg.getId()).thenReturn("cfg-1"); + when(cfg.getName()).thenReturn("my-config"); + when(cfg.getExecutableId()).thenReturn("exec-1"); + when(cfg.getScenarioId()).thenReturn("foundation-models"); + + AiConfigurationList list = mock(AiConfigurationList.class); + when(list.getResources()).thenReturn(List.of(cfg)); + when(configurationApi.query(eq("default"), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(list); + + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Select.from(Configurations_.CDS_NAME) + .where(c -> c.get("resourceGroup_resourceGroupId").eq("default")))); + + verify(configurationApi).query(eq("default"), any(), any(), any(), any(), any(), any(), any()); + assertThat(result.list()).hasSize(1); + assertThat(result.single().get("id")).isEqualTo("cfg-1"); + assertThat(result.single().get("name")).isEqualTo("my-config"); + } + + @Test + void onRead_nullResources_returnsEmptyList() { + AiConfigurationList listWithNullResources = mock(AiConfigurationList.class); + when(listWithNullResources.getResources()).thenReturn(null); + when(configurationApi.query(eq("default"), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(listWithNullResources); + + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Select.from(Configurations_.CDS_NAME) + .where(c -> c.get("resourceGroup_resourceGroupId").eq("default")))); + + assertThat(result.list()).isEmpty(); + } + + @Test + void onCreate_createsConfiguration() { + AiConfigurationCreationResponse response = mock(AiConfigurationCreationResponse.class); + when(response.getId()).thenReturn("new-cfg-id"); + when(configurationApi.create(eq("default"), any(AiConfigurationBaseData.class))) + .thenReturn(response); + + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into(Configurations_.CDS_NAME) + .entry( + Map.of( + "name", "test-config", + "executableId", "exec-1", + "scenarioId", "foundation-models", + "resourceGroup_resourceGroupId", "default")))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiConfigurationBaseData.class); + verify(configurationApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo("test-config"); + assertThat(captor.getValue().getExecutableId()).isEqualTo("exec-1"); + assertThat(captor.getValue().getScenarioId()).isEqualTo("foundation-models"); + assertThat(result.single().get("id")).isEqualTo("new-cfg-id"); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java new file mode 100644 index 0000000..c4fad6c --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -0,0 +1,199 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentCreationResponse; +import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; +import com.sap.ai.sdk.core.model.AiExecutionStatus; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Update; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Integration-style tests for {@link DeploymentHandler} using a real CDS runtime. Only the SDK API + * clients (DeploymentApi, ResourceGroupApi, ConfigurationApi) are mocked since they talk to a + * remote AI Core service. + */ +class DeploymentHandlerTest { + + private static CdsRuntime runtime; + private static RemoteService service; + private static DeploymentApi deploymentApi; + private static ResourceGroupApi resourceGroupApi; + private static ConfigurationApi configurationApi; + + @BeforeAll + static void bootRuntime() { + deploymentApi = mock(DeploymentApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + configurationApi = mock(ConfigurationApi.class); + + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + @BeforeEach + void clearMockInvocations() { + clearInvocations(deploymentApi, resourceGroupApi, configurationApi); + } + + @Test + void onCreate_createsDeploymentWithConfigurationId() { + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("new-dep-id"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); + + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into(Deployments_.CDS_NAME) + .entry( + Map.of( + "configurationId", "cfg-1", + "resourceGroup_resourceGroupId", "default")))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1"); + assertThat(result.single().get("id")).isEqualTo("new-dep-id"); + } + + @Test + void onCreate_withTtl_setsTtlOnRequest() { + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("dep-ttl"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); + + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into(Deployments_.CDS_NAME) + .entry( + Map.of( + "configurationId", "cfg-2", + "ttl", "PT24H", + "resourceGroup_resourceGroupId", "default")))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); + } + + @Test + void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity(Deployments_.CDS_NAME) + .where(d -> d.get("id").eq("dep-123")) + .data("targetStatus", "STOPPED"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("default"), eq("dep-123"), captor.capture()); + assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED"); + } + + @Test + void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity(Deployments_.CDS_NAME) + .where(d -> d.get("id").eq("dep-789")) + .data("configurationId", "config-456"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("default"), eq("dep-789"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456"); + } + + @Test + void onUpdate_withoutTargetStatusOrConfigurationId_throwsBadRequest() { + assertThatThrownBy( + () -> + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity(Deployments_.CDS_NAME) + .where(d -> d.get("id").eq("dep-x")) + .data("ttl", "1d")))) + .isInstanceOfSatisfying( + ServiceException.class, + e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) + .hasMessageContaining("targetStatus") + .hasMessageContaining("configurationId"); + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java new file mode 100644 index 0000000..a8ac72c --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java @@ -0,0 +1,23 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; + +/** Shared test utilities for handler tests that boot a CDS runtime with the AICore model. */ +final class HandlerTestUtils { + + private HandlerTestUtils() {} + + /** Creates CdsProperties with the AICore RemoteService configured. */ + static CdsProperties aicoreProperties() { + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore_.CDS_NAME); + rsConfig.setModel(AICore_.CDS_NAME); + props.getRemote().getServices().put(AICore_.CDS_NAME, rsConfig); + return props; + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java new file mode 100644 index 0000000..abe4335 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -0,0 +1,268 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; +import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Integration-style tests for {@link ResourceGroupHandler} using a real CDS runtime. Only the SDK + * API clients are mocked since they talk to a remote AI Core service. + */ +class ResourceGroupHandlerTest { + + private static CdsRuntime runtime; + private static RemoteService service; + private static ResourceGroupApi resourceGroupApi; + + @BeforeAll + static void bootRuntime() { + resourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); + + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + @BeforeEach + void clearMockInvocations() { + clearInvocations(resourceGroupApi); + } + + @Test + void onRead_returnsAllResourceGroups() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-1"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + + BckndResourceGroupList list = mock(BckndResourceGroupList.class); + when(list.getResources()).thenReturn(List.of(rg)); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())).thenReturn(list); + + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> service.run(Select.from(ResourceGroups_.CDS_NAME))); + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), any()); + assertThat(result.list()).hasSize(1); + assertThat(result.single().get("resourceGroupId")).isEqualTo("rg-1"); + } + + @Test + void onCreate_createsResourceGroup() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into(ResourceGroups_.CDS_NAME) + .entry(Map.of("resourceGroupId", "rg-new")))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + assertThat(captor.getValue().getResourceGroupId()).isEqualTo("rg-new"); + } + + @Test + void onCreate_withTenantId_setsTenantLabel() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into(ResourceGroups_.CDS_NAME) + .entry( + Map.of( + "resourceGroupId", "rg-tenant", + "tenantId", "tenant-a")))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + assertThat(captor.getValue().getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly(tuple(AICoreConfig.TENANT_LABEL_KEY, "tenant-a")); + } + + @Test + void onUpdate_withLabels_callsPatchWithLabels() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-upd"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-upd")).thenReturn(rg); + + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity(ResourceGroups_.CDS_NAME) + .where(d -> d.get("resourceGroupId").eq("rg-upd")) + .data("labels", List.of(Map.of("key", "env", "value", "staging"))))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture()); + assertThat(captor.getValue().getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly(tuple("env", "staging")); + } + + @Test + void onUpdate_withoutLabels_callsPatchWithoutLabels() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-nolabel"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg); + + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity(ResourceGroups_.CDS_NAME) + .where(d -> d.get("resourceGroupId").eq("rg-nolabel")) + .data("statusMessage", "updated"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture()); + assertThat(captor.getValue().getLabels()).isNullOrEmpty(); + } + + /** + * Multi-tenancy tests use a separate runtime with MT enabled to verify tenant-scoped label + * selectors. + */ + @Nested + class MultiTenancyTests { + + private static CdsRuntime mtRuntime; + private static RemoteService mtService; + private static ResourceGroupApi mtResourceGroupApi; + + @BeforeAll + static void bootMtRuntime() { + mtResourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); + + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + mtRuntime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, mtResourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = + new DeploymentResolver(config, deploymentApi, mtResourceGroupApi); + + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.complete(); + + mtService = mtRuntime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + @BeforeEach + void clearMtMockInvocations() { + clearInvocations(mtResourceGroupApi); + } + + @Test + @SuppressWarnings("unchecked") + void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { + BckndResourceGroupList list = mock(BckndResourceGroupList.class); + when(list.getResources()).thenReturn(List.of()); + when(mtResourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(list); + + mtRuntime + .requestContext() + .modifyUser( + u -> + u.setTenant("current-tenant") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("test-user") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> mtService.run(Select.from(ResourceGroups_.CDS_NAME))); + + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); + verify(mtResourceGroupApi) + .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); + assertThat(selectorCaptor.getValue()) + .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=current-tenant"); + } + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java new file mode 100644 index 0000000..a79326f --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -0,0 +1,264 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiDeploymentList; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.ql.Select; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.List; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration-style tests for tenant-scoping logic through actual CQN READ operations. Verifies + * that {@code ensureResourceGroupAccessible} (used by DeploymentHandler and ConfigurationHandler) + * correctly enforces tenant isolation when multi-tenancy is enabled. + */ +class TenantScopingTest { + + private static CdsRuntime runtime; + private static RemoteService service; + private static DeploymentApi deploymentApi; + private static ResourceGroupApi resourceGroupApi; + + @BeforeAll + static void bootRuntime() { + deploymentApi = mock(DeploymentApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); + + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + @BeforeEach + void clearMockInvocations() { + clearInvocations(deploymentApi, resourceGroupApi); + } + + @Test + void matchingTenant_allowsAccess() { + stubResourceGroupWithTenant("rg-a", "tenant-A"); + stubDeploymentQuery(); + + assertThatCode( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-a") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-a"))))) + .doesNotThrowAnyException(); + } + + @Test + void nonMatchingTenant_throws404() { + stubResourceGroupWithTenant("rg-b", "tenant-A"); + + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-B") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-b") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-b"))))) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + @Test + void providerUser_bypassesTenantCheck() { + stubResourceGroupWithTenant("rg-c", "tenant-X"); + stubDeploymentQuery(); + + // System user (provider) should bypass tenant check regardless of tenant label + assertThatCode( + () -> + runtime + .requestContext() + .systemUser() + .run( + (Function) + ctx -> + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-c"))))) + .doesNotThrowAnyException(); + } + + @Test + void nullTenantUser_bypassesTenantCheck() { + stubDeploymentQuery(); + + // Non-system user with null tenant bypasses check (currentTenantId() returns null) + assertThatCode( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant(null) + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("no-tenant-user") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-d"))))) + .doesNotThrowAnyException(); + } + + @Test + void noLabelsOnResourceGroup_throws404() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-no-labels")).thenReturn(rg); + + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-labels") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-no-labels"))))) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + @Test + void emptyLabelsOnResourceGroup_throws404() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getLabels()).thenReturn(List.of()); + when(resourceGroupApi.get("rg-empty-labels")).thenReturn(rg); + + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-empty") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-empty-labels"))))) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + private void stubResourceGroupWithTenant(String rgId, String tenantId) { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreConfig.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn(tenantId); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get(rgId)).thenReturn(rg); + } + + private void stubDeploymentQuery() { + AiDeploymentList emptyList = mock(AiDeploymentList.class); + when(emptyList.getResources()).thenReturn(List.of()); + when(deploymentApi.query(any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(emptyList); + } +} diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md new file mode 100644 index 0000000..03c0a6f --- /dev/null +++ b/cds-feature-recommendations/README.md @@ -0,0 +1,229 @@ +# cds-feature-recommendations + +AI-powered field recommendations for SAP Fiori UIs in CAP Java applications, leveraging SAP AI Core and the SAP-RPT-1 foundation model. + +## How It Works + +The plugin generically hooks into any draft-enabled entity that has properties with a value help. When a user edits a draft record, the plugin: + +1. Fetches historical records as training context +2. Sends context + current row to the provided model (default: RPT-1 model) +3. Returns predictions as `SAP_Recommendations` in the OData response +4. Fiori Elements renders the recommendations as suggestions in form fields + +## Setup + +### Maven + +```xml + + com.sap.cds + cds-feature-recommendations + ${cds-ai.version} + runtime + +``` + +Or use the starter that bundles this with `cds-feature-ai-core`: + +```xml + + com.sap.cds + cds-starter-ai + ${cds-ai.version} + runtime + +``` + +Requires an [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding — see [`cds-feature-ai-core`](../cds-feature-ai-core/README.md) for setup. + +#### CDS Plugin + +Add `@cap-js/ai` to your project's `package.json`: + +```json +{ + "dependencies": { + "@cap-js/ai": "^1", + "@sap/cds": "^9" + } +} +``` + +Then run `npm install`. The plugin hooks into the CDS compiler and automatically adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields. + +Since the Java module `cds-feature-ai-core` already provides the `AICore` service CDS model, disable the duplicate model from `@cap-js/ai` in your `.cdsrc.json`: + +```json +{ + "requires": { + "AICore": { + "model": false + } + } +} +``` + +## Enabling Recommendations + +For recommendations to fire on an entity: + +- The entity must be **draft-enabled** (`@odata.draft.enabled`) +- At least one field must be annotated with a **value list** +- The `SAP_Recommendations` navigation property must be present — either via the CDS plugin (see above) or added manually (see below). Without it, predictions are computed but not serialized in OData responses. + +Recommendations are triggered for fields annotated with `@Common.ValueList`, `@Common.ValueListWithFixedValues`, or whose association target has `@cds.odata.valuelist`: + +```cds +@odata.draft.enabled +entity Books { + key ID : Integer; + title : String(111); + descr : String(1111); + genre : Association to one Genres; + status : Association to one Status; +} + +// Option 1: Annotate the association target +annotate Genres with @cds.odata.valuelist; + +// Option 2: Annotate the field directly +annotate Books with { + status @Common.ValueList: { + CollectionPath: 'Status', + Parameters: [{ + $Type: 'Common.ValueListParameterInOut', + ValueListProperty: 'code', + LocalDataProperty: status_code + }] + } +} +``` + +#### Adding the SAP_Recommendations navigation property manually + +If you cannot use the CDS plugin, add the `SAP_Recommendations` navigation property directly in your CDS model. You need to: + +1. **Define a `RecommendationItem_*` type** for each CDS primitive type used by your value-list fields. Each type must contain the four fixed fields shown below — only `RecommendedFieldValue` varies by type. +2. **Extend each target entity** with a `SAP_Recommendations` composition that has one entry per value-list field, using the field name as the property name and the matching `RecommendationItem_*` type. + +The property names inside `SAP_Recommendations` must exactly match the field names on the entity (e.g. `genre_ID`, `author_ID`). + +```cds +// Define one type per CDS primitive used by your value-list fields +type RecommendationItem_Integer { + RecommendedFieldValue : Integer; + RecommendedFieldDescription : String; + RecommendedFieldScoreValue : Decimal; + RecommendedFieldIsSuggestion: Boolean; +} + +type RecommendationItem_UUID { + RecommendedFieldValue : UUID; + RecommendedFieldDescription : String; + RecommendedFieldScoreValue : Decimal; + RecommendedFieldIsSuggestion: Boolean; +} + +// Extend your entity — one entry per value-list field +extend my.Books with { + SAP_Recommendations: Composition of one { + genre_ID : many RecommendationItem_Integer; + author_ID: many RecommendationItem_UUID; + } +} +``` + +See also the [SAP Fiori Elements – Recommendations documentation](https://help.sap.com/docs/SAPUI5/b2f662dd9d7a4ec680056733050b4d34/1a6324d5ad7f4034a93f911b4e53e080.html). + +### Adding Text Descriptions + +Use `@Common.Text` to show human-readable descriptions alongside recommended values: + +```cds +annotate Books with { + genre @Common.Text: 'genre.name'; +} +``` + +### Disabling Recommendations for a Field + +```cds +annotate Books with { + genre @UI.RecommendationState: 0; +} +``` + +Fields annotated with `@UI.RecommendationState: 0` are excluded from predictions entirely. +A value of `1` (or omitting the annotation) means the field is eligible for recommendations. + +> **Note:** Since `@UI.RecommendationState` is a UI annotation, you must enable UI annotation loading +> in the Java runtime for it to take effect. By default, the CAP Java runtime strips `@UI.*` +> annotations from the in-memory model to reduce memory consumption (they are typically only +> needed for OData metadata generation, not for runtime logic). +> +> ```yaml +> cds: +> model: +> include-ui-annotations: true +> ``` + +## Configuration + +The following configuration applies to the RPT-1 model implementation. + +```yaml +cds: + requires: + recommendations: + contextRowLimit: 2000 # Max historical rows used as training context (RPT-1) +``` + +See [`cds-feature-ai-core`](../cds-feature-ai-core/README.md) for AI Core connection and multi-tenancy configuration. + +## UI Integration + +The plugin adds a `SAP_Recommendations` map to OData read responses for draft entities. Each predicted field contains an array of suggestions: + +```json +{ + "SAP_Recommendations": { + "genre_ID": [ + { + "RecommendedFieldValue": 12, + "RecommendedFieldDescription": "Science Fiction", + "RecommendedFieldScoreValue": 0.5, + "RecommendedFieldIsSuggestion": true + } + ] + } +} +``` + +SAP Fiori Elements automatically renders these as suggestions in form fields when editing a draft. + +## Supported Field Types + +The following field types are supported by the RPT-1 model implementation: + +| Category | Types | +| -------- | ---------------------------------------------------------------------- | +| String | `String`, `LargeString`, `UUID` (treated as string) | +| Numeric | `Integer`, `Int16`, `Int32`, `Int64`, `Integer64`, `Decimal`, `Double` | +| Temporal | `Date`, `Time`, `DateTime`, `Timestamp` | +| Other | `Boolean` | + +Binary, vector, and draft system fields are excluded automatically. + +## Local Development + +Without an AI Core binding, the plugin uses a `MockAIClient` that returns random predictions from existing context rows - useful for UI development without AI Core access. The `@cap-js/ai` CDS plugin is still required for the model enhancement. + +## Related + +- [`cds-feature-ai-core`](../cds-feature-ai-core/README.md) - Required dependency for AI Core connectivity +- [SAP Fiori Elements - Intelligent Suggestions](https://experience.sap.com/fiori-design-web/) + +## Sample + +A runnable CAP Java sample demonstrating this plugin lives at [`samples/bookshop`](../samples/bookshop). The `AdminService.Books` entity is draft-enabled with value-list fields (genre, author) - when editing a book draft, the plugin suggests values based on the existing catalog. diff --git a/cds-feature-recommendations/package-lock.json b/cds-feature-recommendations/package-lock.json new file mode 100644 index 0000000..80af502 --- /dev/null +++ b/cds-feature-recommendations/package-lock.json @@ -0,0 +1,1861 @@ +{ + "name": "cds-feature-recommendations-cds", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cds-feature-recommendations-cds", + "version": "1.0.0", + "devDependencies": { + "@sap/cds-dk": "9.9.1" + } + }, + "node_modules/@sap/cds-dk": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@sap/cds-dk/-/cds-dk-9.9.1.tgz", + "integrity": "sha512-cZoHI/ZhEVffmLo2k9Y/HMR5X+aGCpk60PwJJcZgoat8Kwk6dDl3mUDERhZORQUhp9FwOiyWmNujmNCV8YWWCg==", + "dev": true, + "hasShrinkwrap": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@cap-js/asyncapi": "^1.0.0", + "@cap-js/openapi": "^1.0.0", + "@sap/cds": "^8.3 || ^9", + "@sap/cds-mtxs": "^2 || ^3", + "@sap/hdi-deploy": "^5", + "express": "^4.22.1 || ^5", + "hdb": "^2.0.0", + "livereload-js": "^4.0.1", + "mustache": "^4.0.1", + "ws": "^8.4.2", + "xml-js": "^1.6.11", + "yaml": "^2" + }, + "bin": { + "cds": "bin/cds.js", + "cds-ts": "bin/cds-ts.js", + "cds-tsx": "bin/cds-tsx.js" + }, + "optionalDependencies": { + "@cap-js/sqlite": ">=1" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/asyncapi": { + "version": "1.0.3", + "integrity": "sha512-vZSWKAe+3qfvZDXV5SSFiObGWmqyS9MDyEADb5PLVT8kzO39qGaSDPv/GzI/gwvRfCayGAjU4ThiBKrFA7Gclg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/db-service": { + "version": "2.11.0", + "integrity": "sha512-sl33LcxZYAJgMCQZDw4lMGe4kWYq6685Xc6ze4qcoM+rd6aqiyVsSC6C7XH5yerXs7cVHhRC+Dgo8AsaapFzlQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "generic-pool": "^3.9.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/openapi": { + "version": "1.4.0", + "integrity": "sha512-/LRSwn4SDxAi3qKwl09zoOhEVGaPGlYOPz/0S3UBnaMJVvaLyPiKbbaOtOnrrgulUX5OXt+ujPIQznOsbTzuAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "pluralize": "^8.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/sqlite": { + "version": "2.4.0", + "integrity": "sha512-Ao+AzIN6BWHNpLbGxAzF79OezFNHzDG2srwiBABs0FYxIxEGkc2hg6ETo79pTTt66gcWtx7pWh/N9xk2M6SFBQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@cap-js/db-service": "^2.11.0", + "better-sqlite3": "^12.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8", + "sql.js": "^1.13.0" + }, + "peerDependenciesMeta": { + "sql.js": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@eslint/js": { + "version": "10.0.1", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds": { + "version": "9.9.1", + "integrity": "sha512-GqdsBsRkZThhpOyzj8ihf/jDmf/2zprZFgaun6ZymUw4/ahzjK/bbdd6eQ8txDuv88pnUl2HPFjvUVq3O/6hCA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/cds-compiler": "^6.4", + "@sap/cds-fiori": "^2", + "express": "^4.22.1 || ^5", + "yaml": "^2" + }, + "bin": { + "cds-deploy": "bin/deploy.js", + "cds-serve": "bin/serve.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@eslint/js": "^9 || ^10", + "tar": "^7.5.6" + }, + "peerDependenciesMeta": { + "tar": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-compiler": { + "version": "6.9.1", + "integrity": "sha512-j5C61t1mPhMW3vpD3LIRVn40DMiIF2XahOPeJIPjRpUiGMbQPdVreqAhiRHg39GYhSK6etlr5/MIx3a2ljtqHg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "bin": { + "cdsc": "bin/cdsc.js", + "cdshi": "bin/cdshi.js", + "cdsse": "bin/cdsse.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-fiori": { + "version": "2.3.0", + "integrity": "sha512-6oWov+DSpFrSTgxXR0dZhak6aZ/IVRZvaHERMi0EgSTzIJdlvZlpw3Kf18ePMcTrRrtEXwD4RIjKt8pbs0g2Hg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-mtxs": { + "version": "3.9.0", + "integrity": "sha512-U9H9NXQxlxSNwSD/6U59+Egn9LIE2SRdu8i5bZqEG2GB4xEU6csduy0kY4EWvi8XXD8onbFSgw4AA9SB4pN0Yg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/hdi-deploy": "^5" + }, + "bin": { + "cds-mtx": "bin/cds-mtx.js", + "cds-mtx-migrate": "bin/cds-mtx-migrate.js" + }, + "peerDependencies": { + "@sap/cds": ">=9" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi": { + "version": "4.8.0", + "integrity": "sha512-tkJmY2ffm6mt4/LFwRBihlQkMxNAXa3ngvRe2N/6+qLIsUNdrH/M03S5mkygXq56K+KoVVZYuradajCusMWwsw==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "async": "^3.2.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.5", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi-deploy": { + "version": "5.6.1", + "integrity": "sha512-+qQ7qwG8lko303L5yRj2dud/nDAVuVblV/mmzJT44oPbF0Nry18eD2LUS23hFeuxjRa7rYK5YKQ8ffGgWxVrYQ==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "@sap/hdi": "^4.8.0", + "@sap/xsenv": "^6.0.0", + "async": "^3.2.6", + "dotenv": "^16.4.5", + "handlebars": "^4.7.8", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=18.x" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.6", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/xsenv": { + "version": "6.2.0", + "integrity": "sha512-8jrsX1OAM3YUqGU+4deggqvkxrBrHAPYEllBX0YJfWNffgxSZKHG75bRd/RV6hxPwulPL0DeHfd2eYJMeY5gdw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE file", + "dependencies": { + "debug": "4.4.3", + "node-cache": "^5.1.2", + "verror": "1.10.1" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/accepts": { + "version": "2.0.0", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/assert-plus": { + "version": "1.0.0", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/async": { + "version": "3.2.6", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/base64-js": { + "version": "1.5.1", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/better-sqlite3": { + "version": "12.9.0", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/@sap/cds-dk/node_modules/bindings": { + "version": "1.5.0", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/bl": { + "version": "4.1.0", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/body-parser": { + "version": "2.2.2", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/braces": { + "version": "3.0.3", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/buffer": { + "version": "5.7.1", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/@sap/cds-dk/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/chownr": { + "version": "1.1.4", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/clone": { + "version": "2.1.2", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-disposition": { + "version": "1.1.0", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie-signature": { + "version": "1.2.2", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/core-util-is": { + "version": "1.0.2", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/decompress-response": { + "version": "6.0.0", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/deep-extend": { + "version": "0.6.0", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/detect-libc": { + "version": "2.1.2", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/dotenv": { + "version": "16.6.1", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sap/cds-dk/node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/end-of-stream": { + "version": "1.4.5", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-object-atoms": { + "version": "1.1.1", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/etag": { + "version": "1.8.1", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/expand-template": { + "version": "2.0.3", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/express": { + "version": "5.2.1", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/extsprintf": { + "version": "1.4.1", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/file-uri-to-path": { + "version": "1.0.0", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/fill-range": { + "version": "7.1.1", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/finalhandler": { + "version": "2.1.1", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/fresh": { + "version": "2.0.0", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/fs-constants": { + "version": "1.0.0", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/generic-pool": { + "version": "3.9.0", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/github-from-package": { + "version": "0.0.0", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/handlebars": { + "version": "4.7.9", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/hasown": { + "version": "2.0.3", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb": { + "version": "2.27.1", + "integrity": "sha512-xYL/W+fq2TyGHyzm8muolQnw8tdh4+2NQ8mQP2FpLSuhfJ8l0jQNSUZoAXic7NfMEan1Jvf8V1L4blwkgTc6+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "iconv-lite": "0.7.0" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "lz4-wasm-nodejs": "0.9.2" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb/node_modules/iconv-lite": { + "version": "0.7.0", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/iconv-lite": { + "version": "0.7.2", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/ieee754": { + "version": "1.2.1", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ini": { + "version": "1.3.8", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-number": { + "version": "7.0.0", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-promise": { + "version": "4.0.0", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/livereload-js": { + "version": "4.0.2", + "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/lz4-wasm-nodejs": { + "version": "0.9.2", + "integrity": "sha512-hSwgJPS98q/Oe/89Y1OxzeA/UdnASG8GvldRyKa7aZyoAFCC8VPRtViBSava7wWC66WocjUwBpWau2rEmyFPsw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/math-intrinsics": { + "version": "1.1.0", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/media-typer": { + "version": "1.1.0", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/merge-descriptors": { + "version": "2.0.0", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch": { + "version": "4.0.8", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-types": { + "version": "3.0.2", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/mimic-response": { + "version": "3.1.0", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/minimist": { + "version": "1.2.8", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/mkdirp-classic": { + "version": "0.5.3", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/mustache": { + "version": "4.2.0", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/@sap/cds-dk/node_modules/napi-build-utils": { + "version": "2.0.0", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/negotiator": { + "version": "1.0.0", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/neo-async": { + "version": "2.6.2", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/node-abi": { + "version": "3.92.0", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/node-cache": { + "version": "5.1.2", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/object-inspect": { + "version": "1.13.4", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/on-finished": { + "version": "2.4.1", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/once": { + "version": "1.4.0", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/@sap/cds-dk/node_modules/parseurl": { + "version": "1.3.3", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/path-to-regexp": { + "version": "8.4.2", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/pluralize": { + "version": "8.0.0", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sap/cds-dk/node_modules/prebuild-install": { + "version": "7.1.3", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/proxy-addr": { + "version": "2.0.7", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/pump": { + "version": "3.0.4", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/@sap/cds-dk/node_modules/qs": { + "version": "6.15.1", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/raw-body": { + "version": "3.0.2", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/rc": { + "version": "1.2.8", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/readable-stream": { + "version": "3.6.2", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sap/cds-dk/node_modules/router": { + "version": "2.2.0", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sap/cds-dk/node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/sax": { + "version": "1.6.0", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/semver": { + "version": "7.7.4", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/send": { + "version": "1.2.1", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/serve-static": { + "version": "2.2.1", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/side-channel": { + "version": "1.1.0", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-weakmap": { + "version": "1.0.2", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/simple-concat": { + "version": "1.0.1", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/simple-get": { + "version": "4.0.1", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/string_decoder": { + "version": "1.3.0", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/strip-json-comments": { + "version": "2.0.1", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-fs": { + "version": "2.1.4", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-stream": { + "version": "2.2.0", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/to-regex-range": { + "version": "5.0.1", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/tunnel-agent": { + "version": "0.6.0", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@sap/cds-dk/node_modules/type-is": { + "version": "2.0.1", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/uglify-js": { + "version": "3.19.3", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/util-deprecate": { + "version": "1.0.2", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/vary": { + "version": "1.1.2", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/verror": { + "version": "1.10.1", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/wordwrap": { + "version": "1.0.0", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/wrappy": { + "version": "1.0.2", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ws": { + "version": "8.20.0", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/xml-js": { + "version": "1.6.11", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/yaml": { + "version": "2.8.4", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/cds-feature-recommendations/package.json b/cds-feature-recommendations/package.json new file mode 100644 index 0000000..a2f8eb2 --- /dev/null +++ b/cds-feature-recommendations/package.json @@ -0,0 +1,9 @@ +{ + "name": "cds-feature-recommendations-cds", + "version": "1.0.0", + "private": true, + "description": "CDS build dependencies for cds-feature-recommendations. Pulled in by Maven (cds-maven-plugin npm goal) so a fresh `mvn install` is hermetic and does not require a globally installed @sap/cds-dk.", + "devDependencies": { + "@sap/cds-dk": "9.9.1" + } +} diff --git a/cds-feature-recommendations/pom.xml b/cds-feature-recommendations/pom.xml new file mode 100644 index 0000000..6ee1185 --- /dev/null +++ b/cds-feature-recommendations/pom.xml @@ -0,0 +1,149 @@ + + + 4.0.0 + + + com.sap.cds + cds-ai-root + ${revision} + + + cds-feature-recommendations + jar + + CDS Feature Recommendations + Fiori smart recommendations using AI Core for CAP Java + + + src/test/resources/model/recommendations-test.cds + src/test/resources/model/csn.json + + + + + com.sap.cds + cds-feature-ai-core + + + + com.github.ben-manes.caffeine + caffeine + + + + com.sap.ai.sdk.foundationmodels + sap-rpt + ${ai-sdk.version} + + + + com.sap.cds + cds-services-api + + + + com.sap.cds + cds-services-utils + + + + com.sap.cds + cds-services-impl + + + + + ${project.artifactId} + + + com.sap.cds + cds-maven-plugin + + + cds.install-node + + install-node + + + + cds.npm-ci + + npm + + + ci + + + + cds.compile + + cds + + generate-test-resources + + + compile -2 json "${project.basedir}/${model.cds}" --log-level error > "${project.basedir}/${model.csn}" + + + + + + + + maven-clean-plugin + + + + ./ + + .flattened-pom.xml + + + + + + + auto-clean + + clean + + clean + + + + + + org.jacoco + jacoco-maven-plugin + + + ${excluded.generation.package}**/* + + + + + jacoco-initialize + + prepare-agent + + + + jacoco-site-report-all-tests + + report + + verify + + + jacoco-site-report-only-unit-tests + + report + + test + + + + + + + diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java new file mode 100644 index 0000000..1498d0f --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -0,0 +1,150 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.sap.cds.CdsData; +import com.sap.cds.feature.recommendation.api.RecommendationClient; +import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; +import com.sap.cds.reflect.CdsStructuredType; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.draft.Drafts; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.utils.DraftUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(value = "*", type = ApplicationService.class) +class FioriRecommendationHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); + private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; + private static final String SAP_RECOMMENDATIONS = "SAP_Recommendations"; + + private final RecommendationClientResolver> clientResolver; + private final PersistenceService db; + private final RecommendationResultParser resultParser = new RecommendationResultParser(); + // Avoids re-evaluating the CDS model on every read to check whether an entity has prediction + // columns. Keys are ":" because if an entity needs a prediction can be + // different across tenants. + private final Cache entitiesWithoutPredictionsPerTenant = + Caffeine.newBuilder().maximumSize(10_000).build(); + + FioriRecommendationHandler( + RecommendationClientResolver> clientResolver, PersistenceService db) { + this.clientResolver = clientResolver; + this.db = db; + } + + void invalidateTenant(String tenantId) { + String prefix = tenantKey(tenantId) + ":"; + entitiesWithoutPredictionsPerTenant.asMap().keySet().removeIf(k -> k.startsWith(prefix)); + } + + private static String tenantKey(String tenantId) { + return tenantId != null ? tenantId : ""; + } + + @After(entity = "*") + public void afterRead(CdsReadEventContext context, List dataList) { + CdsStructuredType target = context.getTarget(); + if (target == null) { + return; + } + String tenantId = context.getUserInfo().getTenant(); + String entityName = target.getQualifiedName(); + String cacheKey = tenantKey(tenantId) + ":" + entityName; + if (entitiesWithoutPredictionsPerTenant.getIfPresent(cacheKey) != null) { + return; + } + + if (dataList.size() != 1) { + return; + } + + CdsData row = dataList.get(0); + + if (!DraftUtils.isDraftEnabled(target)) { + return; + } + + if (row.containsKey(Drafts.IS_ACTIVE_ENTITY) + && !Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) { + return; + } + + // rowType reflects the projected shape (columns actually selected); target is the full entity. + // Fall back to target when rowType is absent, e.g. when the result carries no type metadata. + CdsStructuredType rowType = context.getResult().rowType(); + if (rowType == null) { + rowType = target; + } + + int limit = + context + .getCdsRuntime() + .getEnvironment() + .getProperty( + "cds.ai.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); + + var builder = new RecommendationContextBuilder(target, rowType, limit); + + if (builder.predictionElementNames().isEmpty()) { + entitiesWithoutPredictionsPerTenant.put(cacheKey, Boolean.TRUE); + return; + } + + if (builder.keyNames().isEmpty()) { + logger.debug("Entity has no key elements, skipping predictions."); + return; + } + + if (builder.contextColumns().isEmpty()) { + logger.trace("No suitable context columns found, skipping predictions."); + return; + } + + CdsData predictRow = builder.buildPredictRow(row); + if (predictRow == null) { + logger.debug("Current row already has values for all prediction columns, skipping."); + return; + } + + // Result.list() returns List; the ArrayList copy also converts it to List. + List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); + if (contextRows.size() < 2) { + logger.debug("Not enough context rows (minimum 2), skipping predictions."); + return; + } + + List missingPredictionElementNames = + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); + + RecommendationClient client = clientResolver.resolve(builder.keyNames()); + List predictions = + client.predict(predictRow, contextRows, missingPredictionElementNames); + + if (predictions.isEmpty()) { + logger.warn("No predictions returned from AI client."); + return; + } + if (predictions.size() > 1) { + logger.warn("Multiple predictions returned from AI client, but only one was expected."); + return; + } + + Map recommendations = + resultParser.buildRecommendations( + db, predictions.get(0), missingPredictionElementNames, context, rowType); + row.put(SAP_RECOMMENDATIONS, recommendations); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java new file mode 100644 index 0000000..c2c9157 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -0,0 +1,52 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.recommendation.api.RecommendationClient; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +// Mock implementation used when no AI Core binding is present. For each prediction column that +// is null in the predict row, it picks a random non-null value from the same column across the +// context rows and returns it as the prediction. Columns already filled are left unchanged. +class MockRecommendationClient implements RecommendationClient { + + // We use random here so you can see a difference in the UI. The actual value returned here is not + // relevant for tests. + private final Random random = new Random(); + private final List keyNames; + + MockRecommendationClient(List keyNames) { + this.keyNames = keyNames; + } + + @Override + public List predict( + CdsData predictionRow, List contextRows, List predictionColumns) { + String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow); + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if (predictionRow.get(col) == null) { + List availableValues = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object contextValue = + availableValues.isEmpty() + ? null + : availableValues.get(random.nextInt(availableValues.size())); + Map predictionEntry = new HashMap<>(); + // Replace the empty entry in col with a randomly picked value of entries in the + // contextRows. + predictionEntry.put("prediction", contextValue); + prediction.put(col, List.of(predictionEntry)); + } + } + if (!keyNames.isEmpty()) { + prediction.put(indexColumn, predictionRow.get(keyNames.get(0))); + } + return List.of(CdsData.create(prediction)); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java new file mode 100644 index 0000000..773526e --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -0,0 +1,103 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.recommendation.api.RecommendationClient; +import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; +import com.sap.cds.feature.recommendation.api.RptInferenceClient; +import com.sap.cds.feature.recommendation.api.RptModelSpec; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RecommendationConfiguration implements CdsRuntimeConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(RecommendationConfiguration.class); + + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + CdsRuntime runtime = configurer.getCdsRuntime(); + ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); + + RemoteService aiCoreService = serviceCatalog.getService(RemoteService.class, AICore_.CDS_NAME); + + if (aiCoreService == null) { + logger.info("No AICoreService found, skipping Fiori recommendation handler registration."); + return; + } + + PersistenceService db = + serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + if (db == null) { + logger.info( + "No PersistenceService found, skipping Fiori recommendation handler registration."); + return; + } + + boolean hasBind = hasAICoreBinding(runtime); + // The real resolver is a lambda resolved at prediction time. That's necessary because + // resource group and deployment ID are tenant-specific and are only available at + // prediction time from the request context. The RemoteService is captured in the closure. + RecommendationClientResolver> clientResolver = + hasBind + ? keyNames -> resolveRptClient(aiCoreService, keyNames) + : keyNames -> new MockRecommendationClient(keyNames); + + FioriRecommendationHandler handler = new FioriRecommendationHandler(clientResolver, db); + configurer.eventHandler(handler); + configurer.eventHandler(new RecommendationModelChangedHandler(handler)); + } + + private static boolean hasAICoreBinding(CdsRuntime runtime) { + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + } + + private static RecommendationClient resolveRptClient( + RemoteService service, List keyNames) { + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); + if (resourceGroup == null) { + throw new IllegalStateException("Failed to resolve resource group from AI Core service"); + } + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + if (deploymentId == null) { + throw new IllegalStateException( + "Failed to resolve deployment ID for resource group: " + resourceGroup); + } + + InferenceClientContext infCtx = InferenceClientContext.create(); + infCtx.setResourceGroupId(resourceGroup); + infCtx.setDeploymentId(deploymentId); + service.emit(infCtx); + if (infCtx.getResult() == null) { + throw new IllegalStateException( + "Failed to create inference client for deployment: " + deploymentId); + } + + return new RptInferenceClient(infCtx.getResult(), keyNames); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java new file mode 100644 index 0000000..6843533 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -0,0 +1,169 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; + +import com.sap.cds.CdsData; +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsBaseType; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsSimpleType; +import com.sap.cds.reflect.CdsStructuredType; +import com.sap.cds.services.draft.Drafts; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Builds the context data needed for prediction: determines which elements to predict, which + * columns provide context and builds the context query. This class is cds-model aware, but does not + * know about which client will be used for the predictions. + */ +class RecommendationContextBuilder { + + private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; + private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = + "@Common.ValueListWithFixedValues"; + private static final String ODATA_VALUE_LIST_ANNOTATION = "@cds.odata.valuelist"; + private static final String RECOMMENDATION_STATE_ANNOTATION = "@UI.RecommendationState"; + private static final String COMPUTED_ANNOTATION = "@Core.Computed"; + private static final String READONLY_ANNOTATION = "@readonly"; + private static final Set SUPPORTED_CONTEXT_TYPES = + EnumSet.of( + CdsBaseType.STRING, + CdsBaseType.LARGE_STRING, + CdsBaseType.UUID, + CdsBaseType.BOOLEAN, + CdsBaseType.INTEGER, + CdsBaseType.UINT8, + CdsBaseType.INT16, + CdsBaseType.INT32, + CdsBaseType.INT64, + CdsBaseType.INTEGER64, + CdsBaseType.DECIMAL, + CdsBaseType.DOUBLE, + CdsBaseType.DATE, + CdsBaseType.TIME, + CdsBaseType.DATETIME, + CdsBaseType.TIMESTAMP, + CdsBaseType.HANA_SMALLINT, + CdsBaseType.HANA_TINYINT, + CdsBaseType.HANA_SMALLDECIMAL, + CdsBaseType.HANA_REAL, + CdsBaseType.HANA_CHAR, + CdsBaseType.HANA_NCHAR, + CdsBaseType.HANA_VARCHAR, + CdsBaseType.HANA_CLOB); + + private final CdsStructuredType target; + private final CdsStructuredType rowType; + private final int contextRowLimit; + private final List predictionElementNames; + private final List contextColumns; + private final List keyNames; + + RecommendationContextBuilder(CdsStructuredType target, CdsStructuredType rowType, int limit) { + this.target = target; + this.rowType = rowType; + this.contextRowLimit = limit; + this.predictionElementNames = computePredictionElements(); + this.contextColumns = computeContextColumns(); + this.keyNames = target.keyElements().map(CdsElement::getName).toList(); + } + + List predictionElementNames() { + return predictionElementNames; + } + + List contextColumns() { + return contextColumns; + } + + List keyNames() { + return keyNames; + } + + CqnSelect buildContextQuery() { + Set selectColumns = new HashSet<>(contextColumns); + selectColumns.addAll(keyNames); + + var select = + Select.from(target.getQualifiedName()) + .columns(selectColumns.toArray(String[]::new)) + .where( + predictionElementNames.stream() + // the row for which we want to do predictions is automatically + // excluded by this isNotNull check + .map(col -> CQL.get(col).isNotNull()) + .collect(CQL.withAnd())) + .limit(contextRowLimit); + target + // ensure there is some stable ordering of the contextRows, if possible order by + // "most recently changed" so the model gets the most up-to-date data + .concreteNonAssociationElements() + .filter(byAnnotation("cds.on.update")) + .map(CdsElement::getName) + .findFirst() + .or(() -> target.keyElements().map(CdsElement::getName).findFirst()) + .ifPresent(col -> select.orderBy(CQL.get(col).desc())); + return select; + } + + // Builds the predict row from only the allowed columns (same set used in buildContextQuery), + // so draft, computed, and readonly fields are excluded by construction rather than explicit + // removal. + CdsData buildPredictRow(CdsData row) { + if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { + return null; + } + Set allowed = new HashSet<>(contextColumns); + allowed.addAll(keyNames); + Map predictRow = new HashMap<>(); + allowed.forEach( + col -> { + if (row.containsKey(col)) predictRow.put(col, row.get(col)); + }); + return CdsData.create(predictRow); + } + + private List computePredictionElements() { + return rowType + .elements() + .filter( + byAnnotation(VALUE_LIST_ANNOTATION) + .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) + .filter(e -> !e.getType().isAssociation()) + .filter(e -> !Boolean.FALSE.equals(e.getAnnotationValue(ODATA_VALUE_LIST_ANNOTATION, null))) + .filter( + e -> + !isRecommendationDisabled( + e.getAnnotationValue(RECOMMENDATION_STATE_ANNOTATION, null))) + .map(CdsElement::getName) + .toList(); + } + + private List computeContextColumns() { + return rowType + .concreteNonAssociationElements() + .filter( + e -> + e.getType() instanceof CdsSimpleType st + && SUPPORTED_CONTEXT_TYPES.contains(st.getType())) + .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) + .filter(byAnnotation(COMPUTED_ANNOTATION).negate()) + .filter(byAnnotation(READONLY_ANNOTATION).negate()) + .map(CdsElement::getName) + .toList(); + } + + private static boolean isRecommendationDisabled(Object annotationValue) { + return annotationValue instanceof Number n && n.intValue() == 0; + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java new file mode 100644 index 0000000..5ea3944 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java @@ -0,0 +1,36 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.ExtensibilityService; +import com.sap.cds.services.mt.ModelChangedEventContext; + +// TODO Integration test needed for the cache-invalidation behaviour. +// The proper E2E pattern (cf. cds-services ExtendViaSidecarTest) requires: +// - extensibility-enabled mtx-local sidecar (/-/cds/extensibility/set) +// - an extension JSON adding a prediction column to a draft-enabled entity +// - per-tenant SQLite schema that survives the model mutation +// - assert that an OData read returns SAP_Recommendations only AFTER the +// extension is applied AND EVENT_MODEL_CHANGED has been emitted. +// The unit test in FioriRecommendationHandlerTest.invalidateTenant_* +// already covers the cache-invalidation logic in isolation; what is missing +// is the wiring + observable-effect assertion through MockMvc. +@ServiceName(value = ExtensibilityService.DEFAULT_NAME, type = ExtensibilityService.class) +class RecommendationModelChangedHandler implements EventHandler { + + private final FioriRecommendationHandler recommendationHandler; + + RecommendationModelChangedHandler(FioriRecommendationHandler recommendationHandler) { + this.recommendationHandler = recommendationHandler; + } + + @On(event = ExtensibilityService.EVENT_MODEL_CHANGED) + public void onModelChanged(ModelChangedEventContext context) { + String tenantId = context.getUserInfo().getTenant(); + recommendationHandler.invalidateTenant(tenantId); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java new file mode 100644 index 0000000..a76b8d8 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java @@ -0,0 +1,186 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.Select; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsBaseType; +import com.sap.cds.reflect.CdsSimpleType; +import com.sap.cds.reflect.CdsStructuredType; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses AI prediction responses and assembles them into the SAP_Recommendations structure expected + * by Fiori UIs. Handles type coercion, text path resolution, and description lookups. + */ +class RecommendationResultParser { + + private static final Logger logger = LoggerFactory.getLogger(RecommendationResultParser.class); + + Map buildRecommendations( + PersistenceService db, + CdsData prediction, + List predictionElementNames, + CdsReadEventContext context, + CdsStructuredType rowType) { + Map textPaths = resolveTextPaths(predictionElementNames, context); + + Map parsedValues = new HashMap<>(); + for (String col : predictionElementNames) { + Object obj = prediction.get(col); + if (!(obj instanceof List list) + || list.isEmpty() + || !(list.get(0) instanceof Map map)) { + continue; + } + CdsBaseType baseType = + rowType + .findElement(col) + .filter(e -> e.getType().isSimple()) + .map(e -> e.getType().as(CdsSimpleType.class).getType()) + .orElse(CdsBaseType.STRING); + parsedValues.put(col, parseValue(map.get("prediction"), baseType)); + } + + Map descriptions = + resolveDescriptionsBatch(db, parsedValues, textPaths, context); + + Map recommendations = new HashMap<>(); + for (Map.Entry entry : parsedValues.entrySet()) { + String col = entry.getKey(); + Object recommendedValue = entry.getValue(); + Map values = new HashMap<>(); + values.put("RecommendedFieldValue", recommendedValue); + values.put("RecommendedFieldDescription", descriptions.getOrDefault(col, "")); + // The RPT-1 prediction response does not currently expose a per-prediction confidence + // score in a stable form. We emit a constant placeholder so Fiori Elements still renders + // the suggestion with a non-null score; replace with the real probability once the AI SDK + // surfaces it (see SAP-RPT-1 model docs for `prediction_proba`). + values.put("RecommendedFieldScoreValue", 0.5); + values.put("RecommendedFieldIsSuggestion", true); + recommendations.put(col, List.of(values)); + } + return recommendations; + } + + private Object parseValue(Object value, CdsBaseType baseType) { + if (value == null) { + return null; + } + String s = value.toString(); + try { + return switch (baseType) { + case INTEGER, INT16, INT32, UINT8, HANA_SMALLINT, HANA_TINYINT -> Integer.valueOf(s); + case INT64, INTEGER64 -> Long.valueOf(s); + case DECIMAL, DECIMAL_FLOAT, HANA_SMALLDECIMAL -> new BigDecimal(s); + case DOUBLE, HANA_REAL -> Double.valueOf(s); + case BOOLEAN -> Boolean.valueOf(s); + default -> s; + }; + } catch (NumberFormatException e) { + return s; + } + } + + private Map resolveTextPaths( + List predictionElementNames, CdsReadEventContext context) { + CdsStructuredType target = context.getTarget(); + Map fkToAssociation = buildFkToAssociationMap(target); + Map textPaths = new HashMap<>(); + for (String col : predictionElementNames) { + Optional path; + String assocName = fkToAssociation.get(col); + if (assocName != null) { + path = getTextPath(context, assocName); + if (path.isEmpty()) { + path = getTextPath(context, col); + } + } else { + path = getTextPath(context, col); + } + path.ifPresent(p -> textPaths.put(col, p)); + } + return textPaths; + } + + private Map buildFkToAssociationMap(CdsStructuredType target) { + Map map = new HashMap<>(); + target + .associations() + .forEach( + assocElement -> { + CdsAssociationType assocType = assocElement.getType().as(CdsAssociationType.class); + String assocName = assocElement.getName(); + assocType + .refs() + .forEach(ref -> map.put(assocName + "_" + ref.lastSegment(), assocName)); + }); + return map; + } + + private Map resolveDescriptionsBatch( + PersistenceService db, + Map parsedValues, + Map textPaths, + CdsReadEventContext context) { + Map descriptions = new HashMap<>(); + if (textPaths.isEmpty()) { + return descriptions; + } + String entity = context.getTarget().getQualifiedName(); + for (Map.Entry entry : parsedValues.entrySet()) { + String col = entry.getKey(); + String path = textPaths.get(col); + if (path == null) { + continue; + } + String[] parts = path.split("\\."); + if (parts.length != 2) { + logger.debug( + "Text path {} for column {} is not in expected format 'association.textField'.", + path, + col); + continue; + } + Result r = + db.run( + Select.from(entity) + .columns(b -> b.to(parts[0]).get(parts[1]).as("desc")) + .where(CQL.get(col).eq(entry.getValue())) + .limit(1)); + r.first() + .map(row -> row.get("desc")) + .filter(Objects::nonNull) + .ifPresent(d -> descriptions.put(col, d.toString())); + } + return descriptions; + } + + private Optional getTextPath(CdsReadEventContext context, String columnName) { + return context + .getTarget() + .findElement(columnName) + .flatMap(e -> e.findAnnotation("@Common.Text")) + .flatMap( + a -> { + Object val = a.getValue(); + if (val instanceof String s) return Optional.of(s); + if (val instanceof Map m && m.get("=") != null) + return Optional.of(m.get("=").toString()); + return Optional.empty(); + }); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java new file mode 100644 index 0000000..26bc7a4 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java @@ -0,0 +1,25 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.CdsData; +import java.util.List; + +public class RptIndexColumns { + + // RPT-1 requires a single string index column to identify rows in the request/response. + // When the entity has a composite or non-string key, a synthetic string column is used instead. + public static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; + + // Returns the column name to use as the RPT-1 index column. Uses the single key directly if + // it holds a String value; falls back to the synthetic column for composite or non-string keys. + public static String resolveIndexColumn(List keyNames, CdsData sampleRow) { + if (keyNames.size() == 1 && sampleRow.get(keyNames.get(0)) instanceof String) { + return keyNames.get(0); + } + return SYNTHETIC_INDEX_COLUMN; + } + + private RptIndexColumns() {} +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java new file mode 100644 index 0000000..4558ec8 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -0,0 +1,32 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +import com.sap.cds.CdsData; +import java.util.List; + +public interface RecommendationClient { + + /** + * Predicts values for the missing columns of a single entity row. + * + *

Currently limited to a single prediction row. Multiple prediction rows may be supported in + * the future via a separate overload, but are ruled out at two points for now: + * + *

    + *
  1. {@code FioriRecommendationHandler} bails out when the read returns more than one entity, + * so predictions only fire on single-entity reads. + *
  2. {@code FioriRecommendationHandler} also rejects responses with more than one prediction + * back from the model, treating it as an unexpected state. + *
+ * + * @param predictionRow the single entity row to predict values for; prediction columns contain + * null for missing values that the model should fill + * @param contextRows historical rows from the same entity used as training context + * @param predictionColumns names of the columns the model should predict + * @return the predicted values as a list of result rows + */ + List predict( + CdsData predictionRow, List contextRows, List predictionColumns); +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java new file mode 100644 index 0000000..82b2eca --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -0,0 +1,14 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +// A single-method interface so callers can supply a custom client via lambda. +// @FunctionalInterface enforces this and causes a compile error if a second method is ever added. +// The type parameter T allows the resolver to receive any context the client might need (e.g. key +// names). +@FunctionalInterface +public interface RecommendationClientResolver { + + RecommendationClient resolve(T context); +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java new file mode 100644 index 0000000..1af7384 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -0,0 +1,186 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sap.ai.sdk.core.JacksonConfiguration; +import com.sap.ai.sdk.foundationmodels.rpt.generated.client.DefaultApi; +import com.sap.ai.sdk.foundationmodels.rpt.generated.model.PredictRequestPayload; +import com.sap.ai.sdk.foundationmodels.rpt.generated.model.PredictionConfig; +import com.sap.ai.sdk.foundationmodels.rpt.generated.model.PredictionPlaceholder; +import com.sap.ai.sdk.foundationmodels.rpt.generated.model.RowsInnerValue; +import com.sap.ai.sdk.foundationmodels.rpt.generated.model.TargetColumnConfig; +import com.sap.cds.CdsData; +import com.sap.cds.feature.recommendation.RptIndexColumns; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Client for invoking the SAP RPT-1 foundation model for tabular predictions. This class is part of + * the public API and can be used directly by applications that need to perform custom inference + * outside the automatic Fiori recommendation flow. + * + *

Example usage: + * + *

{@code
+ * RemoteService service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
+ * ResourceGroupContext rgCtx = ResourceGroupContext.create();
+ * service.emit(rgCtx);
+ * String rg = rgCtx.getResult();
+ * DeploymentIdContext depCtx = DeploymentIdContext.create();
+ * depCtx.setResourceGroupId(rg);
+ * depCtx.setSpec(RptModelSpec.rpt1());
+ * service.emit(depCtx);
+ * InferenceClientContext infCtx = InferenceClientContext.create();
+ * infCtx.setResourceGroupId(rg);
+ * infCtx.setDeploymentId(depCtx.getResult());
+ * service.emit(infCtx);
+ * RptInferenceClient client = new RptInferenceClient(infCtx.getResult(), keyNames);
+ * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn"));
+ * }
+ */ +public class RptInferenceClient implements RecommendationClient { + + private static final Logger logger = LoggerFactory.getLogger(RptInferenceClient.class); + + // RPT-1 specific: the placeholder value that marks a column as a prediction target in the request + public static final String PREDICTION_PLACEHOLDER = "[PREDICT]"; + + private static final Retry INFERENCE_RETRY = buildInferenceRetry(); + + private final DefaultApi rpt; + private final List keyNames; + + public RptInferenceClient(ApiClient apiClient, List keyNames) { + this.rpt = + new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); + this.keyNames = keyNames; + } + + @Override + public List predict( + CdsData predictionRow, List contextRows, List predictionColumns) { + String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow); + CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); + List allRows = new ArrayList<>(contextRows); + allRows.add(preparedPredictRow); + + PredictRequestPayload request = buildRequest(allRows, predictionColumns, indexColumn, keyNames); + logger.debug( + "Sending prediction request for one row with {} context rows, {} target columns", + contextRows.size(), + predictionColumns.size()); + return Retry.decorateSupplier( + INFERENCE_RETRY, + () -> { + var response = rpt.predict(request); + logger.debug("Prediction response id: {}", response.getId()); + List> raw = + JacksonConfiguration.getDefaultObjectMapper() + .convertValue(response.getPredictions(), new TypeReference<>() {}); + return raw.stream().map(CdsData::create).toList(); + }) + .get(); + } + + // '\0' is used as separator because it cannot appear in database string values + // (VARCHAR/NVARCHAR), so concatenation of any composite key values is guaranteed collision-free. + static String computeSyntheticKey(Map row, List keyNames) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keyNames.size(); i++) { + if (i > 0) sb.append('\0'); + sb.append(keyNames.get(i)).append('\0'); + Object value = row.get(keyNames.get(i)); + if (value != null) sb.append(value); + } + return sb.toString(); + } + + // Returns a copy of the predictRow with a prediction placeholder replacing empty values + // in the predictionColumns - these will get filled by the predict method. + private static CdsData preparePredictRow(CdsData predictRow, List predictionColumns) { + Map preparedPredictRowMap = new HashMap<>(predictRow); + for (String col : predictionColumns) { + preparedPredictRowMap.putIfAbsent(col, PREDICTION_PLACEHOLDER); + } + return CdsData.create(preparedPredictRowMap); + } + + private static PredictRequestPayload buildRequest( + List rows, + List predictionColumns, + String indexColumn, + List keyNames) { + var targetColumns = + predictionColumns.stream() + .map( + col -> + TargetColumnConfig.create() + .name(col) + .predictionPlaceholder(PredictionPlaceholder.create(PREDICTION_PLACEHOLDER)) + .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) + .toList(); + + // RPT-1 requires exactly one string-typed index column per row to identify predictions. + // When the entity key is composite or non-string, then the index column is + // RptIndexColumns.SYNTHETIC_INDEX_COLUMN and we need to compute the syntheticKey for all rows + // before sending them to RPT-1. + boolean syntheticKeyNeeded = RptIndexColumns.SYNTHETIC_INDEX_COLUMN.equals(indexColumn); + var sdkRows = + rows.stream() + .map( + row -> { + Map sdkRow = toSdkRow(row); + if (syntheticKeyNeeded) { + sdkRow.put( + RptIndexColumns.SYNTHETIC_INDEX_COLUMN, + RowsInnerValue.create(computeSyntheticKey(row, keyNames))); + } + return sdkRow; + }) + .toList(); + + return PredictRequestPayload.create() + .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) + .rows(sdkRows) + .indexColumn(indexColumn); + } + + // Converts a CdsData row to the RPT SDK row format, i.e., into Map + private static Map toSdkRow(CdsData row) { + Map sdkRow = new HashMap<>(); + row.forEach( + (k, v) -> { + if (v != null) { + sdkRow.put(k, RowsInnerValue.create(v.toString())); + } + }); + return sdkRow; + } + + private static Retry buildInferenceRetry() { + RetryConfig config = + RetryConfig.custom() + .maxAttempts(3) + .intervalFunction(IntervalFunction.ofExponentialBackoff(500, 2.0, 5000)) + .retryOnException( + e -> + e instanceof OpenApiRequestException oae + && oae.statusCode() != null + && (oae.statusCode() == 403 + || oae.statusCode() == 404 + || oae.statusCode() == 412)) + .build(); + return Retry.of("rpt-inference", config); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptModelSpec.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptModelSpec.java new file mode 100644 index 0000000..2dd1b20 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptModelSpec.java @@ -0,0 +1,43 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; +import com.sap.ai.sdk.foundationmodels.rpt.RptModel; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import java.util.List; +import java.util.Map; + +public final class RptModelSpec { + + public static final String SCENARIO_ID = "foundation-models"; + public static final String EXECUTABLE_ID = "aicore-sap"; + public static final String CONFIG_NAME = "sap-rpt-1-small"; + public static final String MODEL_NAME = "sap-rpt-1-small"; + public static final String MODEL_VERSION = "latest"; + + private RptModelSpec() {} + + public static ModelDeploymentSpec rpt1() { + return new ModelDeploymentSpec( + SCENARIO_ID, + EXECUTABLE_ID, + CONFIG_NAME, + List.of( + AiParameterArgumentBinding.create().key("modelName").value(MODEL_NAME), + AiParameterArgumentBinding.create().key("modelVersion").value(MODEL_VERSION)), + deployment -> { + var details = deployment.getDetails(); + if (details == null || details.getResources() == null) { + return false; + } + if (details.getResources().getBackendDetails() instanceof Map map + && map.get("model") instanceof Map model + && model.get("name") instanceof String name) { + return RptModel.SAP_RPT_1_SMALL.name().equals(name); + } + return false; + }); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/package-info.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/package-info.java new file mode 100644 index 0000000..316fd91 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/package-info.java @@ -0,0 +1,10 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +/** + * Public API of the {@code cds-feature-recommendations} plugin. + * + *

Types in this package form the stable contract that applications and other plugins program + * against. Implementation classes live in sibling internal packages and may change without notice. + */ +package com.sap.cds.feature.recommendation.api; diff --git a/cds-feature-recommendations/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/cds-feature-recommendations/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..e8fa07d --- /dev/null +++ b/cds-feature-recommendations/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.feature.recommendation.RecommendationConfiguration diff --git a/cds-feature-recommendations/src/main/resources/spotbugs-exclusion-filter.xml b/cds-feature-recommendations/src/main/resources/spotbugs-exclusion-filter.xml new file mode 100644 index 0000000..ee4e277 --- /dev/null +++ b/cds-feature-recommendations/src/main/resources/spotbugs-exclusion-filter.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java new file mode 100644 index 0000000..d3f46e9 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -0,0 +1,572 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.ResultBuilder; +import com.sap.cds.feature.recommendation.api.RecommendationClient; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.services.Service; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.impl.utils.CdsServiceUtils; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class FioriRecommendationHandlerTest { + + private static CdsRuntime runtime; + private static PersistenceService db; + + private FioriRecommendationHandler cut; + private RecommendationClient predictionClient; + + @BeforeAll + static void bootRuntime() { + db = mock(PersistenceService.class); + when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + CdsProperties properties = new CdsProperties(); + properties.getModel().setIncludeUiAnnotations(true); + runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(properties)) + .cdsModel("model/csn.json") + .serviceConfigurations() + .service(db) + .complete(); + } + + @BeforeEach + void setup() { + reset(db); + when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + predictionClient = randomPickClient(); + cut = new FioriRecommendationHandler((keyNames) -> predictionClient, db); + } + + // ── tests ────────────────────────────────────────────────────────────────── + + @Test + void emptyRows_returnsEarlyWithoutPredictions() { + runIn( + () -> { + CdsReadEventContext ctx = readContext("test.Books", List.of()); + cut.afterRead(ctx, List.of()); + verifyNoInteractions(db); + }); + } + + @Test + void multipleRows_returnsEarlyWithoutPredictions() { + runIn( + () -> { + List> rows = + List.of( + Map.of("ID", "1", "IsActiveEntity", false), + Map.of("ID", "2", "IsActiveEntity", false)); + CdsReadEventContext ctx = readContext("test.Books", rows); + cut.afterRead(ctx, dataList(rows)); + verifyNoInteractions(db); + }); + } + + @Test + void activeEntity_returnsEarlyWithoutPredictions() { + runIn( + () -> { + List> rows = List.of(Map.of("ID", "1", "IsActiveEntity", true)); + CdsReadEventContext ctx = readContext("test.Books", rows); + cut.afterRead(ctx, dataList(rows)); + verifyNoInteractions(db); + }); + } + + @Test + void noPredictionColumns_returnsEarlyWithoutPredictions() { + runIn( + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + verifyNoInteractions(db); + }); + } + + @Test + void notEnoughContextRows_returnsEarlyWithoutRecommendations() { + runIn( + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows(List.of(Map.of("ID", "x1", "genre_ID", 12))).result()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).doesNotContainKey("SAP_Recommendations"); + }); + } + + @Test + void allColumnsAlreadyFilled_returnsEarlyWithoutRecommendations() { + runIn( + () -> { + Map row = draftRow("genre_ID", 16); + row.put("currency_code", "USD"); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).doesNotContainKey("SAP_Recommendations"); + }); + } + + @Test + void emptyPredictions_returnsEarlyWithoutRecommendations() { + runIn( + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + predictionClient = (predictionRow, contextRows, cols) -> List.of(); + cut.afterRead(ctx, dataList(row)); + assertThat(row).doesNotContainKey("SAP_Recommendations"); + }); + } + + @Test + void multiplePredictions_returnsEarlyWithoutRecommendations() { + runIn( + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + predictionClient = + (predictionRow, contextRows, cols) -> + List.of( + CdsData.create(Map.of("ID", "id-1")), CdsData.create(Map.of("ID", "id-2"))); + cut.afterRead(ctx, dataList(row)); + assertThat(row).doesNotContainKey("SAP_Recommendations"); + }); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void draftRow_withGenreAndCurrency_addsSapRecommendations() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("currency_code", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + Map.of("ID", "id-1", "genre_ID", 12, "currency_code", "USD"), + Map.of("ID", "id-2", "genre_ID", 16, "currency_code", "GBP")))) + .result(), + ResultBuilder.selectedRows(List.of()).result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).containsKey("SAP_Recommendations"); + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat((List) recs.get("genre_ID")).hasSize(1); + assertThat((List) recs.get("currency_code")).hasSize(1); + }); + } + + @Test + void contextQuery_excludesPredictionRowByRequiringNonNullPredictionColumns() { + runIn( + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + // The WHERE clause requires all prediction columns to be non-null, so the current row + // (which has genre_ID = null) is automatically excluded from the context. + String selectSql = selectCaptor.getAllValues().get(0).toString(); + assertThat(selectSql).contains("\"is not\",\"null\""); + assertThat(selectSql).contains("genre_ID"); + }); + } + + @Test + void cdsoDataValueListFalse_fieldIsExcludedFromPredictions() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("suppressed_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithDisabledValueList", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of("ID", "x1", "genre_ID", 1, "suppressed_ID", 10)), + new HashMap<>( + Map.of("ID", "x2", "genre_ID", 2, "suppressed_ID", 20))))) + .result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // genre_ID has @Common.ValueListWithFixedValues → predicted + // suppressed_ID has @cds.odata.valuelist: false → excluded + assertThat(row).containsKey("SAP_Recommendations"); + @SuppressWarnings("unchecked") + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(recs).containsKey("genre_ID"); + assertThat(recs).doesNotContainKey("suppressed_ID"); + }); + } + + @Test + @SuppressWarnings("unchecked") + void recommendationStateZero_fieldIsExcludedFromPredictions() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("disabled_ID", null); + row.put("enabled_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithRecommendationState", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of( + "ID", + "x1", + "genre_ID", + 1, + "disabled_ID", + 10, + "enabled_ID", + 100)), + new HashMap<>( + Map.of( + "ID", + "x2", + "genre_ID", + 2, + "disabled_ID", + 20, + "enabled_ID", + 200))))) + .result(), + ResultBuilder.selectedRows(List.of()).result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // genre_ID has no @UI.RecommendationState → predicted + // disabled_ID has @UI.RecommendationState: 0 → excluded + // enabled_ID has @UI.RecommendationState: 1 → predicted + assertThat(row).containsKey("SAP_Recommendations"); + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(recs).containsKey("genre_ID"); + assertThat(recs).doesNotContainKey("disabled_ID"); + assertThat(recs).containsKey("enabled_ID"); + }); + } + + @Test + void recommendationStateZero_fieldIsExcludedFromContextQuery() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("disabled_ID", null); + row.put("enabled_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithRecommendationState", List.of(row)); + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of( + "ID", + "x1", + "genre_ID", + 1, + "disabled_ID", + 10, + "enabled_ID", + 100)), + new HashMap<>( + Map.of( + "ID", + "x2", + "genre_ID", + 2, + "disabled_ID", + 20, + "enabled_ID", + 200))))) + .result(), + ResultBuilder.selectedRows(List.of()).result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // The context query WHERE clause should NOT require disabled_ID to be non-null + // (it is still a valid context *column* for SELECT, but not a prediction target) + CqnSelect contextQuery = selectCaptor.getAllValues().get(0); + String whereSql = contextQuery.where().map(Object::toString).orElse(""); + assertThat(whereSql).contains("genre_ID"); + assertThat(whereSql).contains("enabled_ID"); + assertThat(whereSql).doesNotContain("disabled_ID"); + }); + } + + @Test + void blobAndVectorFields_areExcludedFromContextSelect() { + runIn( + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + String selectSql = selectCaptor.getAllValues().get(0).toString(); + assertThat(selectSql).doesNotContain("image"); + assertThat(selectSql).doesNotContain("embedding"); + assertThat(selectSql).contains("genre_ID"); + }); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void nonIdKey_usesSyntheticKeyColumn() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("isbn", "978-3-16"); + row.put("IsActiveEntity", false); + row.put("category_ID", null); + CdsReadEventContext ctx = readContext("test.IsbnBooks", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>(Map.of("isbn", "978-1-01", "category_ID", 1)), + new HashMap<>(Map.of("isbn", "978-1-02", "category_ID", 2))))) + .result()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).containsKey("SAP_Recommendations"); + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat((List) recs.get("category_ID")).hasSize(1); + }); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void composedKeys_usesSyntheticKeyColumn() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("order_ID", 1); + row.put("item_no", 10); + row.put("IsActiveEntity", false); + row.put("category_ID", null); + CdsReadEventContext ctx = readContext("test.OrderItems", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of("order_ID", 1, "item_no", 1, "category_ID", 1)), + new HashMap<>( + Map.of("order_ID", 1, "item_no", 2, "category_ID", 2))))) + .result()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).containsKey("SAP_Recommendations"); + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat((List) recs.get("category_ID")).hasSize(1); + }); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void rptStyleClient_filledColumns_areExcludedFromRecommendations() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", 12); + row.put("currency_code", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + twoContextRows(), + ResultBuilder.selectedRows(List.of()).result(), + ResultBuilder.selectedRows(List.of()).result()); + predictionClient = rptStyleClient(); + cut.afterRead(ctx, dataList(row)); + assertThat(row).containsKey("SAP_Recommendations"); + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(recs).doesNotContainKey("genre_ID"); + assertThat((List) recs.get("currency_code")).hasSize(1); + }); + } + + @Test + void invalidateTenant_removesOnlyThatTenantsEntries() { + // populate cache for two tenants + runInTenant( + "tenant-a", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + }); + runInTenant( + "tenant-b", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + }); + + cut.invalidateTenant("tenant-a"); + + // tenant-a entry gone → db is called again (no early exit) + runInTenant( + "tenant-a", + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).containsKey("SAP_Recommendations"); + }); + + // tenant-b entry still cached → db is NOT called + reset(db); + when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + runInTenant( + "tenant-b", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + verifyNoInteractions(db); + }); + } + + // ── helpers ──────────────────────────────────────────────────────────────── + + private CdsReadEventContext readContext(String entityName, List> resultRows) { + CdsReadEventContext ctx = CdsReadEventContext.create(entityName); + CdsServiceUtils.getEventContextSPI(ctx).setService(runtimeService()); + Result result = + ResultBuilder.selectedRows(resultRows) + .rowType(runtime.getCdsModel().getEntity(entityName)) + .result(); + ctx.setResult(result); + return ctx; + } + + private static Service runtimeService() { + return runtime.getServiceCatalog().getServices().findFirst().orElseThrow(); + } + + private void runIn(Runnable test) { + runtime.requestContext().run((Consumer) rc -> test.run()); + } + + private void runInTenant(String tenantId, Runnable test) { + runtime.requestContext().systemUser(tenantId).run((Consumer) rc -> test.run()); + } + + private Map draftRow(String col, Object val) { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put(col, val); + return row; + } + + private static List dataList(Map row) { + return List.of(CdsData.create(row)); + } + + private static List dataList(List> rows) { + return rows.stream().map(CdsData::create).toList(); + } + + private static Result twoContextRows() { + return ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + Map.of("ID", "x1", "genre_ID", 12, "currency_code", "USD"), + Map.of("ID", "x2", "genre_ID", 16, "currency_code", "GBP")))) + .result(); + } + + private static RecommendationClient rptStyleClient() { + Random random = new Random(42); + return (predictionRow, contextRows, predictionColumns) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + List available = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); + prediction.put(col, List.of(Map.of("prediction", val))); + } + prediction.put("ID", predictionRow.get("ID")); + return List.of(CdsData.create(prediction)); + }; + } + + private static RecommendationClient randomPickClient() { + Random random = new Random(42); + return (predictionRow, contextRows, predictionColumns) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if (predictionRow.get(col) == null) { + List available = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); + prediction.put(col, List.of(Map.of("prediction", val))); + } + } + prediction.put("ID", predictionRow.get("ID")); + return List.of(CdsData.create(prediction)); + }; + } +} diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java new file mode 100644 index 0000000..ee9ee52 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -0,0 +1,57 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RecommendationConfigurationTest { + + @Mock private CdsRuntimeConfigurer configurer; + @Mock private CdsRuntime runtime; + @Mock private ServiceCatalog serviceCatalog; + @Mock private CdsEnvironment environment; + @Mock private RemoteService aiCoreService; + @Mock private PersistenceService persistenceService; + + @Test + void aiCoreServiceFound_registersHandler() { + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(runtime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + when(serviceCatalog.getService(RemoteService.class, AICore_.CDS_NAME)) + .thenReturn(aiCoreService); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); + + new RecommendationConfiguration().eventHandlers(configurer); + + verify(configurer).eventHandler(any(FioriRecommendationHandler.class)); + } + + @Test + void aiCoreServiceNull_doesNotRegisterHandler() { + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(serviceCatalog.getService(RemoteService.class, AICore_.CDS_NAME)).thenReturn(null); + + new RecommendationConfiguration().eventHandlers(configurer); + + verify(configurer, never()).eventHandler(any()); + } +} diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java new file mode 100644 index 0000000..54586bb --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -0,0 +1,76 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.recommendation.RptIndexColumns; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RptInferenceClientTest { + + @Test + void resolveIndexColumn_singleStringKey_usesItDirectly() { + CdsData row = CdsData.create(Map.of("isbn", "978-3-16")); + assertThat(RptIndexColumns.resolveIndexColumn(List.of("isbn"), row)).isEqualTo("isbn"); + } + + @Test + void resolveIndexColumn_singleUuidKey_usesItDirectly() { + CdsData row = CdsData.create(Map.of("ID", "a009c640-434a-4542-ac68-51b400c880ec")); + assertThat(RptIndexColumns.resolveIndexColumn(List.of("ID"), row)).isEqualTo("ID"); + } + + @Test + void resolveIndexColumn_singleIntegerKey_usesSyntheticColumn() { + CdsData row = CdsData.create(Map.of("order_ID", 42)); + assertThat(RptIndexColumns.resolveIndexColumn(List.of("order_ID"), row)) + .isEqualTo("SAP_RECOMMENDATIONS_ID"); + } + + @Test + void resolveIndexColumn_compositeKey_usesSyntheticColumn() { + CdsData row = CdsData.create(Map.of("order_ID", 1, "item_no", 10)); + assertThat(RptIndexColumns.resolveIndexColumn(List.of("order_ID", "item_no"), row)) + .isEqualTo("SAP_RECOMMENDATIONS_ID"); + } + + @Test + void computeSyntheticKey_singleKey() { + String key = RptInferenceClient.computeSyntheticKey(Map.of("ID", "abc"), List.of("ID")); + assertThat(key).isEqualTo("ID" + '\0' + "abc"); + } + + @Test + void computeSyntheticKey_compositeKey() { + String key = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", 1, "item_no", 10), List.of("order_ID", "item_no")); + assertThat(key).isEqualTo("order_ID" + '\0' + "1" + '\0' + "item_no" + '\0' + "10"); + } + + @Test + void computeSyntheticKey_noCollision_betweenDifferentCompositions() { + // "1" + "0" must not produce the same key as "10" + "" + String key1 = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", "1", "item_no", "0"), List.of("order_ID", "item_no")); + String key2 = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", "10", "item_no", ""), List.of("order_ID", "item_no")); + assertThat(key1).isNotEqualTo(key2); + } + + @Test + void computeSyntheticKey_nullValue_doesNotCrash() { + Map row = new java.util.HashMap<>(); + row.put("order_ID", 1); + row.put("item_no", null); + String key = RptInferenceClient.computeSyntheticKey(row, List.of("order_ID", "item_no")); + assertThat(key).isEqualTo("order_ID" + '\0' + "1" + '\0' + "item_no" + '\0'); + } +} diff --git a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds new file mode 100644 index 0000000..3e5f45d --- /dev/null +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -0,0 +1,83 @@ +namespace test; + +// genre_ID and currency_code are declared as plain scalars here because this is a test model. +// In a real CDS model these would be generated foreign key columns from annotated associations. +@odata.draft.enabled +entity Books { + key ID : UUID; + title : String; + @Common.ValueListWithFixedValues + genre_ID : Integer; + @Common.Text : genre.name + genre : Association to Genres; + @Common.ValueListWithFixedValues + currency_code : String(3); + @(Common.Text: { $value: ![currency.name] }) + currency : Association to Currencies; + image : LargeBinary; + embedding : Vector(8); +} + +entity Genres { + key ID : Integer; + name : String; +} + +entity Currencies { + key code : String(3); + name : String; +} + +@odata.draft.enabled +entity OrderItems { + key order_ID : Integer; + key item_no : Integer; + @Common.ValueListWithFixedValues + category_ID : Integer; +} + +@odata.draft.enabled +entity IsbnBooks { + key isbn : String; + @Common.ValueListWithFixedValues + category_ID : Integer; +} + +entity PlainEntity { + key ID : UUID; + title : String; +} + +@odata.draft.enabled +entity BooksWithDisabledValueList { + key ID : UUID; + @Common.ValueListWithFixedValues + genre_ID : Integer; + @Common.ValueListWithFixedValues + @cds.odata.valuelist: false + suppressed_ID : Integer; +} + +@odata.draft.enabled +entity BooksWithRecommendationState { + key ID : UUID; + @Common.ValueListWithFixedValues + genre_ID : Integer; + @Common.ValueListWithFixedValues + @UI.RecommendationState: 0 + disabled_ID : Integer; + @Common.ValueListWithFixedValues + @UI.RecommendationState: 1 + enabled_ID : Integer; +} + +service TestService { + entity Books as projection on test.Books; + entity Genres as projection on test.Genres; + entity Currencies as projection on test.Currencies; + entity OrderItems as projection on test.OrderItems; + entity IsbnBooks as projection on test.IsbnBooks; + entity PlainEntity as projection on test.PlainEntity; + entity BooksWithDisabledValueList as projection on test.BooksWithDisabledValueList; + entity BooksWithRecommendationState as projection on test.BooksWithRecommendationState; +} diff --git a/cds-feature-sap-document-ai/README.md b/cds-feature-sap-document-ai/README.md new file mode 100644 index 0000000..e7977ba --- /dev/null +++ b/cds-feature-sap-document-ai/README.md @@ -0,0 +1,447 @@ +# SAP Document AI Plugin for SAP Cloud Application Programming Model (CAP) (Alpha Version) + +A CAP Java plugin that integrates [SAP Document AI](https://help.sap.com/docs/document-ai?locale=en-US) into CDS applications. The plugin exposes a CDS event-based API for submitting documents, manages asynchronous polling against the Document AI service, and delivers results via a CDS outbound event - backed by the CDS persistent outbox for resilience across restarts. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Prerequisites](#prerequisites) +- [Integration Guide](#integration-guide) +- [Usage](#usage) + - [CDS Model](#cds-model) +- [Bookshop Sample](#bookshop-sample) + - [Running without a Document AI service binding](#running-without-a-die-service-binding) + - [Running with a Document AI service binding (hybrid mode)](#running-with-a-die-service-binding-hybrid-mode) +- [Configuration](#configuration) + - [Document AI Service Binding](#die-service-binding) + - [Outbox](#outbox) + - [Degraded Operation](#degraded-operation) +- [Architecture Overview](docs/architecture.md) +- [Supported Plans and APIs](#supported-plans-and-apis) +- [Known Limitations](#known-limitations) +- [Monitoring and Logging](#monitoring-and-logging) +- [References](#references) +- [Support, Feedback, Contributing](#support-feedback-contributing) +- [Integration Tests](#integration-tests) + +--- + +## Quick Start + +1. Add the `sap-document-ai` Maven dependency to your application's `pom.xml`. +2. Enable the CDS persistent outbox scheduler in `application.yaml`. +3. Emit a `DocumentExtraction` event from any `ApplicationService`. +4. Implement a `DocumentExtractionResult` event handler class in your application to process the extracted data. + +For a working reference, see the [Bookshop Sample](#bookshop-sample), which demonstrates a complete integration using an in-memory database. + +--- + +## Prerequisites + +| Requirement | Minimum version | +| --------------- | ----------------------------------------------------------------------------- | +| Java | 17+ | +| Maven | 3.9+ | +| CAP Java | 4.9.x (LTS) | +| SAP Cloud SDK | 5.28.0+ | +| Node.js | Required only for the build-time `cds` CLI (`@sap/cds-dk`) | +| SAP BTP service | Document AI service instance with label `sap-document-information-extraction` | + +All plugin dependencies are declared with `provided` scope and are available on the classpath of any standard CAP Spring Boot application. + +--- + +## Repository Structure + +| Module | What it is | +| ----------------------------- | -------------------------------------------------------------------- | +| `cds-feature-sap-document-ai` | The plugin itself - handlers, services, CDS models, HTTP client | +| `samples/bookshop` | A working reference app showing the plugin integrated end-to-end | +| `integration-tests/spring` | Spring Boot integration tests using a stub Document AI client and H2 | + +--- + +## Integration Guide + +This section walks through integrating the plugin into an existing CAP Java application from start to finish. + +### Step 1 - Add the dependency + +Declare the plugin in `srv/pom.xml`: + +```xml + + com.sap.cds + sap-document-ai + 1.0-SNAPSHOT + +``` + +Ensure the `cds-maven-plugin` is configured with the `resolve` goal so the plugin's CDS models are pulled into the build: + +```xml + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + cds.resolve + + resolve + + + + +``` + +### Step 2 - Enable the persistent outbox + +Add the following to `src/main/resources/application.yaml`: + +```yaml +cds: + outbox: + persistent: + scheduler: + enabled: true +``` + +Without this, documents will be submitted to Document AI but results will never be retrieved. + +### Step 3 - Bind the Document AI service + +**On SAP BTP (Cloud Foundry):** Bind your application to a Document AI service instance. The plugin discovers the binding at startup and activates extraction processing automatically. + +**For local development**, use the `cds bind` hybrid profile to forward credentials from a CF-hosted service instance: + +```bash +cf login +cds bind --to +``` + +This creates a `[hybrid]` profile entry in `.cdsrc-private.json`. Do not commit this file - it contains environment-specific binding references. Then run the application with the hybrid profile: + +```bash +cds bind --exec mvn spring-boot:run +``` + +Without a binding, the plugin starts in degraded mode - extraction events are accepted and jobs are created in `PENDING` status, but no actual processing occurs. See [Degraded Operation](#degraded-operation) for details. + +### Step 4 - Emit a DocumentExtraction event + +From any event handler or service method in your application, emit a `DocumentExtraction` event: + +```java +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; + +DocumentExtraction payload = DocumentExtraction.create(); +payload.setFileName("invoice.pdf"); +payload.setMimeType("application/pdf"); +payload.setContent(inputStream); +payload.setOptions("{\"schemaId\": \"my-schema-id\"}"); + +DocumentExtractionContext ctx = DocumentExtractionContext.create(); +ctx.setData(payload); +myApplicationService.emit(ctx); +``` + +The call returns immediately. The plugin handles submission and schedules polling asynchronously. + +### Step 5 - Handle the result + +Implement an event handler in your application to receive the extraction output once the Document AI service reports the job as complete: + +```java +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; + +@ServiceName(value = "*", type = ApplicationService.class) +public class MyExtractionResultHandler implements EventHandler { + + @On(event = DocumentExtractionResultContext.CDS_NAME) + public void onExtractionComplete(DocumentExtractionResultContext context) { + DocumentExtractionResult result = context.getData(); + String jobId = result.getJobId(); + String resultJson = result.getExtractionResult(); + // process the extracted data + context.setCompleted(); + } +} +``` + +### Step 6 - Build and run + +```bash +mvn compile +mvn spring-boot:run +``` + +Submit a document via your application. The plugin logs progress at `INFO` level - look for `[sap-document-ai]` prefixed entries to trace the job from submission through to result delivery. See [Monitoring and Logging](#monitoring-and-logging) for how to enable debug-level output. + +--- + +## Usage + +> **Note:** In the current version, document extraction can only be triggered programmatically via event emission, as shown in the [Integration Guide](#integration-guide). Annotation-based triggering (e.g. declaratively marking an entity field or action to trigger extraction) is not yet supported and is planned for a future release. + +> **Note:** Multitenancy is not implemented in the current version and is planned for a future release. Until then, the plugin should only be used in single-tenant deployments. + +### CDS Model + +The plugin registers its CDS models automatically via the CAP plugin mechanism. No `using` declarations are required in the application model. + +The plugin exposes the service `sap.document.ai.DocumentAiService` with two events: + +| Event | Direction | Description | +| -------------------------- | ------------------------------------ | ---------------------------------------------- | +| `DocumentExtraction` | Inbound - emitted by the application | Triggers document extraction | +| `DocumentExtractionResult` | Outbound - emitted by the plugin | Delivers the extraction result upon completion | + +**`DocumentExtraction` payload:** + +| Field | Type | Description | +| ---------- | ------------- | -------------------------------------------------------- | +| `fileName` | `String` | File name forwarded to the Document AI service | +| `mimeType` | `String` | MIME type of the document (e.g. `application/pdf`) | +| `content` | `LargeBinary` | Document byte stream | +| `options` | `LargeString` | JSON options string passed to Document AI; may be `null` | + +The `options` field maps directly to the Document AI API's `options` body parameter. Refer to the [SAP Document AI's API documentation](https://help.sap.com/docs/document-ai/sap-document-ai/upload-document?locale=en-US&q=submit+document) for the full options schema. + +**`DocumentExtractionResult` payload:** + +| Field | Type | Description | +| ------------------ | ------------- | -------------------------------------------------- | +| `jobId` | `String` | Plugin-internal extraction job identifier | +| `documentAiJobId` | `String` | Job identifier assigned by the Document AI service | +| `extractionResult` | `LargeString` | Raw JSON extraction result returned by Document AI | + +--- + +## Bookshop Sample + +A runnable CAP Java bookshop demonstrating this plugin lives at [`samples/bookshop`](../samples/bookshop). The `SupplierInvoicesService` showcases a realistic document extraction flow: upload a supplier PDF invoice, extract vendor, date, total and line items, and populate the invoice entity. + +**Prerequisites:** Java 17, Maven 3.9+, Node.js (required by the `cds` CLI invoked during the Maven build). + +### Running without a Document AI service binding + +The sample can be started locally without any service binding. Extraction jobs will be created in `PENDING` status and no actual processing will occur, but the full application and UI are functional for integration exploration. + +```bash +mvn -f samples/bookshop/pom.xml clean install +cd samples/bookshop/srv +mvn spring-boot:run +``` + +### Running with a Document AI service binding (hybrid mode) + +To run the sample with a real Document AI service instance, the SAP BTP Cloud Foundry environment is used via the `cds bind` hybrid profile. + +**Prerequisites:** The `@sap/cds-dk` CLI installed, and CF CLI logged in to the org and space where the Document AI service instance is provisioned. + +**Step 1 - Log in to Cloud Foundry:** + +```bash +cf login +``` + +**Step 2 - Bind the Document AI service instance:** + +```bash +cd samples/bookshop +cds bind --to <> +``` + +This creates or updates `.cdsrc-private.json` with a `[hybrid]` profile entry pointing to the CF service instance and its service key. The file should not be committed to version control as it contains environment-specific binding references. + +**Step 3 - Compile and run with the hybrid profile:** + +```bash +cd samples/bookshop +mvn compile +cds bind --exec mvn spring-boot:run +``` + +The plugin will resolve the Document AI service binding at startup, construct an OAuth2-authenticated destination, and activate extraction processing. + +The `AdminService` exposes a `Books` entity with a bound action `extractDocumentData()` illustrating how to trigger extraction from a CAP action. The `Attachments` composition on `Books` provides a Fiori UI for file upload and is used here purely as a convenient way to supply documents in the sample. The CAP Attachments plugin is not a dependency of this plugin - document storage and retrieval are outside the scope of `cds-feature-sap-document-ai`, which is concerned solely with submitting documents to SAP Document AI and delivering the extracted results. + +--- + +## How It Works + +The plugin follows a fire-and-forget event model with asynchronous result delivery: + +1. **Application emits** a `DocumentExtraction` event containing the document bytes and extraction options. +2. **Plugin submits** the document to the Document AI REST API and persists an `ExtractionJob` in `SUBMITTED` status. +3. **Plugin polls** the Document AI service via the CDS persistent outbox until the job reaches a terminal status. +4. **Plugin emits** a `DocumentExtractionResult` event containing the raw extraction JSON once the job completes. +5. **Application handles** the result event and processes the extracted fields. + +### Job Status Flow + +``` +PENDING --> SUBMITTED --> RUNNING --> DONE + | | | + +------------>+----------->+-------> FAILED +``` + +- `PENDING` - job created, awaiting submission (Document AI may be unavailable) +- `SUBMITTED` - document accepted by Document AI +- `RUNNING` - Document AI is processing the document +- `DONE` - extraction completed successfully; result delivered +- `FAILED` - unrecoverable error at any stage + +See [Architecture Overview](docs/architecture.md) for the full component breakdown and lifecycle diagram. + +--- + +## Configuration + +### Document AI Service Binding + +The plugin resolves Document AI credentials from the SAP BTP service binding environment at startup. It searches for a binding with the service label `sap-document-information-extraction`. + +**SAP BTP (Cloud Foundry / Kubernetes):** Bind the application to a Document AI service instance by referring to [Cloud Foundry](https://help.sap.com/docs/document-ai/sap-document-ai/enabling-service-in-cloud-foundry-environment?locale=en-US&q=submit+document) or [Kuberenetes](https://help.sap.com/docs/document-ai/sap-document-ai/enabling-service-in-kyma-environment?locale=en-US&q=submit+document) documentation. The plugin discovers the binding, constructs an OAuth2-authenticated HTTP destination via the SAP Cloud SDK, and activates extraction processing. + +**Local development:** The plugin starts in degraded mode when no binding is present (see [Degraded Operation](#degraded-operation)). A local binding can be simulated via `VCAP_SERVICES` or a service binding file bearing the label `sap-document-information-extraction`. + +If the binding is present but the destination cannot be initialised (for example, due to a network or configuration error), the plugin logs a warning and disables extraction until the application is restarted. + +### Outbox + +The plugin relies on the CDS persistent outbox to schedule polling cycles. The following configuration is required in `application.yaml`: + +```yaml +cds: + outbox: + persistent: + scheduler: + enabled: true +``` + +Without the persistent outbox, documents are submitted to Document AI but results are never retrieved. + +The plugin submits a polling task named `document-ai-poll-extraction-jobs` to the outbox at 3-second intervals (default) while active jobs exist. Polling stops automatically once all jobs reach a terminal status (`DONE` or `FAILED`) and resumes upon the next document submission. + +The poll interval can be configured in `application.yaml`: + +```yaml +cds: + document-ai: + polling: + interval-seconds: 3 # default +``` + +The outbox retry limit can be adjusted alongside other outbox services: + +```yaml +cds: + outbox: + services: + DefaultOutboxUnordered: + maxAttempts: 10 +``` + +### Degraded Operation + +The plugin is designed to accept events and preserve job state even when dependent services are unavailable. + +| Condition | Behaviour | +| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| No Document AI service binding found at startup | `DocumentExtraction` events are accepted; jobs are created with status `PENDING`; polling is not scheduled | +| Document AI binding present but destination initialisation fails | Same as above; a warning is logged | +| Persistent outbox not configured | Documents are submitted to Document AI; the polling task is not persisted and results are not delivered | +| Document AI returns a non-2xx HTTP response | The affected job is marked `FAILED`; an error is logged | +| Concurrent status update detected | The update is skipped; the later writer's state is preserved (optimistic locking) | + +--- + +## Architecture Overview + +For a detailed description of the plugin's design, component responsibilities, extraction lifecycle, and status state machine, see [here](docs/architecture.md). + +--- + +## Supported Plans and APIs + +The plugin communicates with the SAP Document Information Extraction service via its **REST API** (`document-information-extraction/v1`). This is supported across all available Document AI service plans. + +| Document AI Service Plan | Supported | +| ------------------------ | ------------------ | +| All plans | Yes - via REST API | + +**Future:** Support for the Document AI **OData API** is planned for a future release. This would enable richer query capabilities over extraction results directly through the CAP OData layer. + +--- + +## Monitoring and Logging + +All plugin log statements are prefixed with `[sap-document-ai]` to facilitate log filtering. The plugin uses SLF4J and is configured through the standard logging framework of the host application. + +| Level | Logged events | +| ------- | -------------------------------------------------------------------------------------------------------------------- | +| `INFO` | Service binding resolution, job creation, status transitions, result emission | +| `WARN` | Missing binding, unavailable outbox, jobs skipped due to missing Document AI job ID, concurrent update conflicts | +| `ERROR` | Submission failures, non-2xx Document AI responses, polling exceptions | +| `DEBUG` | Per-cycle active job counts, Document AI status poll responses, idempotent update skips, poll schedule confirmations | + +To enable debug-level logging for the plugin, add the following to `application.yaml`: + +```yaml +logging: + level: + com.sap.cds.feature.documentai: DEBUG +``` + +--- + +## References + +- [Getting Started with CAP](https://cap.cloud.sap/docs/get-started/) +- [CAP Java](https://cap.cloud.sap/docs/java/) +- [Service Consumption using Service Bindings](https://cap.cloud.sap/docs/java/cqn-services/remote-services#native-consumption) +- [Outbox](https://cap.cloud.sap/docs/java/outbox#concepts) + - [Technical Outbox API](https://cap.cloud.sap/docs/java/outbox#technical-outbox-api) +- [SAP Document AI Docs](https://help.sap.com/docs/document-ai?locale=en-US) +- [Enabling Document AI Service Instance on SAP BTP Cloud Foundry](https://help.sap.com/docs/document-ai/sap-document-ai/enabling-service-in-cloud-foundry-environment?locale=en-US) + +--- + +## Support, Feedback, Contributing + +- Bug reports and feature requests should be submitted as issues in this project repository. +- Pull requests are welcome. All contributions must pass `mvn verify`, which enforces Spotless code formatting (Google Java Format), PMD static analysis, and a minimum JaCoCo instruction coverage of 85%. + +--- + +## Integration Tests + +Spring Boot tests are implemented in the `integration-tests/` folder. The tests are executed during the build of the project in the GitHub Actions. + +The folder contains a simple Spring Boot application backed by an in-memory H2 database. No Document AI service binding is required - the tests use a stub `DocumentAiClient` that returns controlled responses. + +The following scenarios are covered: + +- Plugin startup - service catalog registration and schema initialisation +- Document submission via the CAP event API +- Full extraction lifecycle (PENDING → SUBMITTED → RUNNING → DONE and FAILED paths) +- Parallel document processing in a single poll cycle +- Poll cycle resilience when one job's Document AI call fails +- Graceful degradation when no Document AI binding is present +- Rejection of invalid state machine transitions +- `DocumentExtractionResult` CAP event emission on job completion + +To run the tests locally, first install the plugin snapshot, then run `mvn verify` from the `integration-tests/` folder: + +```bash +cd sap-document-ai && mvn install -DskipTests +cd ../integration-tests && npm install && mvn verify +``` diff --git a/cds-feature-sap-document-ai/docs/architecture.md b/cds-feature-sap-document-ai/docs/architecture.md new file mode 100644 index 0000000..146f830 --- /dev/null +++ b/cds-feature-sap-document-ai/docs/architecture.md @@ -0,0 +1,226 @@ +# Implementation Details + +## Table of Contents + +- [Links](#links) +- [Folder Structure](#folder-structure) +- [Feature](#feature) + - [CDS Model](#cds-model) + - [Configuration](#configuration) + - [Handlers](#handlers) + - [Services](#services) + - [Outbox and Polling](#outbox-and-polling) + - [Exceptions](#exceptions) +- [Extraction Lifecycle](#extraction-lifecycle) +- [Status State Machine](#status-state-machine) +- [Tests](#tests) + - [Unit Tests](#unit-tests) +- [Quality Tools](#quality-tools) + +--- + +## Links + +- [CAP Java Plugin Concept](https://cap.cloud.sap/docs/java/building-plugins#building-plugins) +- [CAP Java Outbox Documentation](https://cap.cloud.sap/docs/java/outbox#outboxing-cap-service-events) +- [SAP Document AI Documentation](https://help.sap.com/docs/document-ai?locale=en-US) +- [Enabling Document AI Service on SAP BTP Cloud Foundry](https://help.sap.com/docs/document-ai/sap-document-ai/enabling-service-in-cloud-foundry-environment?locale=en-US) +- [CAP Java Getting Started](https://cap.cloud.sap/docs/java/getting-started) + +--- + +## Folder Structure + +| Folder | Description | +| ------------------------------------------------------ | -------------------------------------------------------------------------- | +| `sap-document-ai` | Core implementation of the Document AI plugin | +| `sap-document-ai/src/main/java` | Java source files for handlers, services, configuration, and model classes | +| `sap-document-ai/src/main/resources/cds` | CDS model files shipped with the plugin | +| `sap-document-ai/src/main/resources/META-INF/services` | Java `ServiceLoader` registration for `CdsRuntimeConfiguration` | +| `sap-document-ai/src/test/java` | Unit tests | +| `bookshop` | Sample CAP Java application demonstrating plugin integration | +| `bookshop/srv` | Spring Boot application module for the sample | +| `bookshop/db` | CDS data model for the sample | +| `bookshop/app` | Fiori UI applications for the sample | +| `docs` | Design and architecture documentation | + +--- + +## Feature + +The plugin is implemented in the `sap-document-ai` module. The following Java packages make up the implementation: + +| Package | Description | +| --------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `com.sap.cds.feature.documentai.configuration` | Bootstraps all plugin components and registers them with the CDS runtime at startup | +| `com.sap.cds.feature.documentai.handlers` | CDS event handlers for document submission and outbox-driven polling | +| `com.sap.cds.feature.documentai.service` | Core extraction service, processing service, status enum, and transition validator | +| `com.sap.cds.feature.documentai.service.client` | HTTP client abstraction for the Document AI REST API | +| `com.sap.cds.feature.documentai.service.model` | Immutable value objects used as internal data transfer types | +| `com.sap.cds.feature.documentai.service.exceptions` | Typed exceptions for error classification | +| `com.sap.cds.feature.documentai.service.utils` | Utility classes | + +### CDS Model + +The CDS model is defined in: + +``` +sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/ +``` + +Per the [CAP Java plugin concept](https://cap.cloud.sap/docs/java/building-plugins#building-plugins), this path makes the model available to consuming applications via the `cds-maven-plugin` `resolve` goal. + +The model contains the following files: + +| File | Description | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `document-ai-service.cds` | Defines `DocumentAiService` with the `DocumentExtraction` (inbound) and `DocumentExtractionResult` (outbound) events | +| `extraction-job.cds` | Defines the internal `ExtractionJob` entity used to persist job state across the extraction lifecycle | +| `index.cds` | Entry point that imports both files; resolved by the CAP plugin mechanism | + +The `ExtractionJob` entity uses `cuid` (auto-generated UUID primary key) and `managed` (auto-populated audit fields). It tracks the job `status`, `tenantId`, the Document AI-assigned `documentAiJobId`, and the raw `extractionResult`. The table is deployed automatically as part of the consuming application's CDS schema deployment - no manual DDL is required. + +### Configuration + +`DocumentAiServiceConfiguration` implements `CdsRuntimeConfiguration` and is the plugin's sole entry point into the CDS runtime. It is discovered automatically via the Java `ServiceLoader` mechanism. + +At startup it: + +- Registers `ExtractionServiceImpl` as a named CDS service in the service catalog. +- Resolves the Document AI service binding from the environment by the label `sap-document-information-extraction`. +- Constructs an OAuth2-authenticated HTTP destination via the SAP Cloud SDK if a binding is found. +- Wires all resolved dependencies into `ExtractionServiceImpl`. +- Registers `DocumentSubmissionHandler` unconditionally. +- Registers `ExtractionPollingHandler` only when a valid Document AI client was successfully built. + +If no binding is found or the destination cannot be initialised, the plugin starts in degraded mode - events are accepted and jobs are queued as `PENDING`, but no extraction processing occurs. + +### Handlers + +| Handler | Description | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DocumentSubmissionHandler` | Listens for `DocumentExtraction` events on any `ApplicationService`. Service-name-agnostic by design - consumers emit events from their own service without coupling to the plugin's internal service name. Delegates to `ExtractionService` and completes the event context. | +| `ExtractionPollingHandler` | Registered against the persistent unordered outbox. Polls the Document AI service for all active jobs on each invocation. Self-reschedules after the configured interval if jobs remain active. Stops automatically when all jobs reach a terminal status. | + +### Services + +| Service / Class | Description | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ExtractionService` | CAP service interface registered in the service catalog. Exposes `triggerExtraction()` for new submissions and `updateExtractionResult()` for poll-driven status updates. | +| `ExtractionServiceImpl` | Central orchestrator. Creates and persists extraction jobs, coordinates submission via the processing service, schedules polling via the outbox, and enforces the status state machine on every update using optimistic locking. | +| `DocumentAiProcessingService` | Abstraction over the HTTP client. Provides an `isAvailable()` check that allows `ExtractionServiceImpl` to degrade gracefully when no Document AI binding is present. | +| `DefaultDocumentAiClient` | Concrete HTTP client. Submits documents to Document AI via a multipart `POST` and polls job status via `GET`. All Document AI communication is authenticated via SAP Cloud SDK OAuth2 destinations. | +| `StatusTransitionValidator` | Stateless utility that enforces the permitted status transitions. Called before every status update to prevent invalid state machine transitions. | + +### Outbox and Polling + +The plugin uses the CDS **persistent unordered outbox** for all polling scheduling rather than a background thread or fixed scheduler. This design choice means: + +- Polling is entirely **event-driven** - it runs only when there are active jobs. +- No background thread or fixed scheduler is active when the system is idle. +- Resilience across restarts is guaranteed - if the application restarts mid-poll, the outbox re-delivers the pending event automatically. +- Polling stops automatically when all jobs reach a terminal status (`DONE` or `FAILED`) and resumes when the next document is submitted. + +The poll interval defaults to 3 seconds and is configurable via `cds.document-ai.polling.interval-seconds` in `application.yaml`. + +### Exceptions + +Errors from Document AI interactions are classified into three typed exceptions nested under `DocumentAiException`: + +| Exception | Condition | +| ---------------------------------- | --------------------------------------------------------------------------------- | +| `DocumentAiException.Connectivity` | Network-level failure reaching Document AI (timeout, DNS, etc.) | +| `DocumentAiException.Request` | Non-2xx HTTP response from Document AI; carries the status code and response body | +| `DocumentAiException.Processing` | Malformed or missing fields in the Document AI response | + +Two additional exceptions govern internal state management: + +| Exception | Condition | +| ---------------------------------- | --------------------------------------------------------------------------------------------------- | +| `ConcurrentJobUpdateException` | Raised when an optimistic lock update detects that a concurrent writer has already advanced the job | +| `IllegalStatusTransitionException` | Raised when a requested status transition is not permitted by the state machine | + +--- + +## Extraction Lifecycle + +``` +Application + └─ emit DocumentExtraction(fileName, mimeType, content, options) + │ + ▼ +DocumentSubmissionHandler + └─ ExtractionService.triggerExtraction() + │ + ├─ Persist ExtractionJob (status=PENDING) + │ + ├─ Document AI unavailable ──► return PENDING result + │ + └─ Document AI available + └─ POST multipart document to Document AI + └─ receive dieJobId + └─ update job → SUBMITTED + └─ submit poll task to outbox + │ + ▼ (after configured interval, via outbox) + ExtractionPollingHandler + └─ GET Document AI job status for each SUBMITTED / RUNNING job + ├─ RUNNING → update job → RUNNING, reschedule + ├─ DONE → update job → DONE + │ emit DocumentExtractionResult + │ └─ consumer @On handler invoked + └─ FAILED → update job → FAILED (terminal) +``` + +--- + +## Status State Machine + +``` +PENDING ──► SUBMITTED ──► RUNNING ──► DONE + │ │ │ + └────────►────┴────────►───┴──────► FAILED +``` + +| Transition | Trigger | +| --------------------- | --------------------------------------------------------------------- | +| `PENDING → SUBMITTED` | Document successfully submitted to Document AI | +| `PENDING → FAILED` | Unrecoverable error during submission | +| `SUBMITTED → RUNNING` | Document AI reports that the job is in progress | +| `SUBMITTED → DONE` | Document AI reports completion without an intermediate RUNNING status | +| `SUBMITTED → FAILED` | Document AI reports a processing failure | +| `RUNNING → DONE` | Document AI processing completed successfully | +| `RUNNING → FAILED` | Document AI reports a processing failure | + +`DONE` and `FAILED` are terminal states. No further transitions are permitted from either status. + +--- + +## Tests + +### Unit Tests + +Unit tests are located in `sap-document-ai/src/test/java`. Each production class has a corresponding test class. The following test classes are implemented: + +| Test Class | What is tested | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| `DocumentSubmissionHandlerTest` | Event handler delegation, PENDING and FAILED logging | +| `ExtractionServiceImplTest` | Job creation, submission flow, concurrent update handling, failure marking, outbox scheduling | +| `ExtractionPollingHandlerTest` | Poll cycle logic, Document AI status mapping, result emission, self-rescheduling, per-job error isolation | +| `DefaultDocumentAiClientTest` | HTTP submit and poll calls, response parsing, error wrapping for all three exception types | +| `DocumentAiServiceConfigurationTest` | Startup wiring, binding resolution, conditional handler registration | +| `StatusTransitionValidatorTest` | All valid and invalid transitions | +| `ExceptionsTest` | Exception message and cause propagation | + +Tests use Mockito for dependencies and AssertJ for assertions. The `jacoco-maven-plugin` enforces a minimum instruction coverage of **85%** across the plugin bundle (generated code excluded). + +--- + +## Quality Tools + +| Tool | Definition | Description | +| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | +| Spotless | `sap-document-ai/pom.xml` | Enforces Google Java Format and SAP license headers on all source files | +| PMD / CPD | `sap-document-ai/pom.xml` | Static analysis and copy-paste detection; SAP Cloud SDK ruleset applied; generated code excluded | +| JaCoCo | `sap-document-ai/pom.xml` | Enforces 85% minimum instruction coverage; generated code excluded | +| Maven Compiler | `sap-document-ai/pom.xml` | Enforces Java 17 (`--release 17`) | diff --git a/cds-feature-sap-document-ai/docs/handover.md b/cds-feature-sap-document-ai/docs/handover.md new file mode 100644 index 0000000..f814cf6 --- /dev/null +++ b/cds-feature-sap-document-ai/docs/handover.md @@ -0,0 +1,152 @@ +# Handover Document - SAP Document AI CAP Java Plugin + +This document captures what has been built, where things stand today, and directions for where the project should go next - based on my understanding (Samyuktha Prabhu [samyuktha.prabhu@sap.com]) at the time of handover. + +## Table of Contents + +- [Getting Started](#getting-started) +- [What Is Built](#what-is-built) +- [Current Capabilities & Limitations](#current-capabilities--limitations) +- [Implementation Notes](#implementation-notes) +- [Suggested Future Work](#suggested-future-work) + +--- + +## Getting Started + +### Read the docs in this order + +1. **[README.md](../README.md)** - start here. Covers what the plugin does, how to integrate it into a CAP Java application, configuration options, and how to run the bookshop sample locally. +2. **[architecture.md](architecture.md)** - read this second. Covers the component breakdown, the extraction lifecycle diagram, and the job status state machine. Gives you the internal picture once the README has explained the external API. +3. **This document (handover.md)** - read last. Covers current maturity, known limitations, implementation notes, and suggestions for future work. Most relevant if you are continuing development rather than just consuming the plugin. + +--- + +## What Is Built + +The plugin is a CAP Java plugin that lets any CAP Spring Boot application send documents to SAP Document AI service on BTP for information extraction and receive the results back - all without writing any HTTP, polling, or job management code. + +### Current Maturity: Alpha / MVP + +| Implemented | Not yet implemented | +| ------------------------------------------------------------------- | --------------------------- | +| Core asynchronous extraction workflow | Multitenancy | +| Event-based API (`DocumentExtraction` / `DocumentExtractionResult`) | Annotation-based triggering | +| Persistent outbox polling with configurable interval | Automatic field mapping | +| Unit and integration tests (85% coverage enforced) | Job recovery on restart | +| Working reference app (`bookshop`) | Richer local mock mode | + +--- + +## Current Capabilities & Limitations + +This is an alpha release. The core extraction pipeline works end-to-end, and the foundation has been laid for a number of possible extensions. The table below summarises what is and isn't in scope today. See [Suggested Future Work](#suggested-future-work) for ideas on where things could go next. + +| # | Area | Where things stand | +| --- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S1 | **Multitenancy** | `tenantId` is stored on `ExtractionJob` as groundwork, but polling and the HTTP client are not yet tenant-aware. Single-tenant use only for now. | +| S2 | **Triggering** | Programmatic triggering (emit `DocumentExtraction` from code) works fully. Declarative triggering via a CDS annotation + Fiori Elements button is not yet implemented. | +| S3 | **Document storage** | The plugin is focused on extraction only. Applications are responsible for storing documents using their preferred mechanism.
**Design Decision**: The plugin accepts document bytes directly on the `DocumentExtraction` event and has no knowledge of where those bytes came from. The plugin's job is extraction, not storage. A hard dependency on `@cap-java/cds-feature-attachments` would couple two independent plugins and create version compatibility overhead. Keeping the plugin storage-agnostic means it works with any document source - the Attachments plugin, a custom entity, an external store, or a direct upload. Each consuming application can choose its own storage strategy and feed documents into the plugin through the same event API regardless. | +| S4 | **Custom schema sync** | Standard document types work out of the box. CDS-annotation-driven sync of custom extraction schemas to Document AI is not yet implemented. | +| S5 | **Field mapping** | Results are delivered as raw JSON for maximum flexibility. Automatic mapping to CDS entity properties and Fiori form pre-fill is not yet implemented. | +| S6 | **Job recovery** | Graceful degradation works (no binding → jobs stay `PENDING`). Automatic recovery of stuck or in-flight jobs on startup is not yet implemented. | +| S7 | **Local development** | Degraded mode works without a binding. A richer mock that returns configurable static results is not yet implemented. | +| S8 | **Malware scanning** | Not yet assessed or implemented. | +| S9 | **CLI scaffolding** | Setup is manual and documented in the README. A `cds add document-ai` command is not yet implemented. | +| S10 | **OData API** | REST API works across all plans. OData support for higher-tier plans is a future enhancement. | +| S11 | **Job cleanup** | Job records are kept for observability. A configurable retention policy is not yet implemented. | +| S12 | **Multiple bindings** | One Document AI binding is resolved at startup. Routing documents to different instances by type, region, or business unit is not yet supported. | + +--- + +## Implementation Notes + +### Poll cycle queries all tenants + +The poll query fetches all active jobs across all tenants. In a multitenant deployment, jobs from different tenants get polled under the same credentials - which is incorrect. This is something to keep in mind if multitenancy becomes a priority. + +### Service binding is resolved once at startup + +If a binding is added after the app starts, it won't be picked up until a restart. The plugin resolves bindings once, at startup. + +### Optimistic lock uses two database round-trips + +`ExtractionServiceImpl.updateExtractionJob()` does a `SELECT` to read the current status, then an `UPDATE … WHERE status = currentStatus`. This is correct and safe, but the SELECT is an extra round-trip that could be eliminated by collapsing into a single read-modify-write statement, which would reduce the conflict window under high concurrency. + +--- + +## Suggested Future Work + +These are ideas and suggestions and not a fixed plan. The ordering reflects what felt most important at the time of writing, but the incoming team should feel free to reprioritise based on their own context and stakeholder needs. + +### 1. Multitenancy _(S1)_ + +A natural first area to tackle would be making each tenant use its own Document AI credentials with isolated jobs. The polling logic and HTTP client would need to become tenant-aware - the `tenantId` field is already on `ExtractionJob`, so no schema migration is needed. + +### 2. Annotation-Based Triggering _(S2)_ + +One possible enhancement is to allow developers to annotate a CDS entity field with `@DocumentAI` to automatically trigger extraction - removing the need for boilerplate event emission. This could cover both the backend (plugin reacts to annotated field writes) and the Fiori Elements UI (an "Upload & Extract" button injected automatically on the Object Page). + +### 3. Job Recovery on Startup _(S6)_ + +A useful addition could be a startup check for any jobs left in `PENDING`, `SUBMITTED`, or `RUNNING` status from before a restart, resuming polling for them automatically rather than waiting for a new submission to arrive. + +### 4. Extraction Progress Indicator + +The backend already tracks `SUBMITTED` and `RUNNING` states - it could be worth surfacing that status in the Fiori Elements Object Page as a visible progress indicator or status strip so users have feedback while extraction is running. + +### 5. Automatic Field Mapping _(S5)_ + +One idea is to have the plugin match extracted fields to CDS entity properties by name convention and pre-fill the Fiori form automatically. Fields below a configurable confidence threshold could be visually flagged (e.g. amber highlight) so users know what to double-check before saving. + +### 6. Document Viewer with Extraction Highlights and Human-in-the-Loop Verification + +A more exploratory idea is to visualise extracted fields on the document itself - bounding boxes colour-coded by confidence level, with click-to-focus between the document viewer and the form. Bounding box coordinates come back from Document AI and would need to be stored alongside the extraction result and exposed to a UI viewer component. + +It could also be worth visually marking AI-extracted fields in the Fiori form (e.g. a distinct badge or icon) so users always know which values were filled by the model. This distinction could persist until a human explicitly confirms or edits the value. + +A further possibility is a human-in-the-loop confirmation step: the user reviews the extracted fields, corrects any errors, and explicitly confirms the result. This confirmed payload could be submitted back to Document AI as ground-truth feedback to activate the [instant learning](https://help.sap.com/docs/document-ai/sap-document-ai/instant-learning?locale=en-US) feature, improving model accuracy for that schema over time. + +### 7. Document AI Outbound Channels - Push-Based Result Delivery + +Document AI supports outbound channels at the schema level: notification channels (status pushes) and extension channels (callbacks triggered after prediction). One option worth exploring is registering the plugin as a target so Document AI pushes results to it directly, eliminating the need to poll. The `DocumentAiClient` interface and `ExtractionService.updateExtractionResult()` are already the right place to plug this in. + +### 8. Custom Schema Synchronisation _(S4)_ + +One possible enhancement is to let developers define custom document type extraction schemas in the CDS model via annotations, with the plugin syncing these to Document AI automatically at deploy time or startup - removing the need for manual configuration in the Document AI workspace. + +### 9. Customisable Extraction Templates + +Right now, every submission requires constructing the Document AI `options` JSON by hand. A template mechanism could let developers define named configurations - document type, schema ID, field selection, confidence thresholds - declaratively in the CDS model or `application.yaml`, and just reference the template name at submission time. + +### 10. Local Mock Mode _(S7)_ + +A mock mode returning configurable static extraction results without a real Document AI binding would make local development more convenient. + +### 11. Application-Level Outbound Channels + +The plugin currently delivers results only via the `DocumentExtractionResult` CDS event. It could be worth exploring additional delivery channels so consuming applications can receive results through whatever channel fits their architecture. + +### 12. `cds add document-ai` Scaffold Command _(S9)_ + +A `cds add document-ai` CLI command could set up the Document AI service binding in `mta.yaml`, enable the persistent outbox in `application.yaml`, and generate boilerplate handler stubs - lowering the barrier significantly for new adopters. + +### 13. OData API Support _(S10)_ + +For applications on higher-tier plans, it could be worth exploring the Document AI OData API as an alternative transport, enabling richer querying and result navigation. + +### 14. Terminal Job Cleanup _(S11)_ + +A configurable retention policy that deletes or archives `ExtractionJob` rows after they've been in `DONE` or `FAILED` status for a set period would prevent unbounded table growth on high-volume deployments. + +### 15. Malware Scanning _(S8)_ + +It may be worth assessing whether documents should be scanned via SAP Malware Scanning Service before being forwarded to Document AI - particularly for multitenant deployments where uploaded content is less trusted. + +### 16. Multiple Service Binding Support _(S12)_ + +Supporting multiple Document AI bindings could allow applications to route documents to different instances based on context - document type, region, business unit. This would need a binding selection strategy, either convention-based or configurable via annotations or `application.yaml`. + +--- + +The plugin is stable at MVP level and provides a solid foundation for further development. The core extraction lifecycle, persistence model, and event-based architecture are in place. The suggestions in this document reflect the state of the project at the time of handover and are intended to provide context, not prescribe a roadmap. diff --git a/cds-feature-sap-document-ai/package-lock.json b/cds-feature-sap-document-ai/package-lock.json new file mode 100644 index 0000000..8b5bf8e --- /dev/null +++ b/cds-feature-sap-document-ai/package-lock.json @@ -0,0 +1,1861 @@ +{ + "name": "cds-feature-sap-document-ai-cds", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cds-feature-sap-document-ai-cds", + "version": "1.0.0", + "devDependencies": { + "@sap/cds-dk": "9.9.1" + } + }, + "node_modules/@sap/cds-dk": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@sap/cds-dk/-/cds-dk-9.9.1.tgz", + "integrity": "sha512-cZoHI/ZhEVffmLo2k9Y/HMR5X+aGCpk60PwJJcZgoat8Kwk6dDl3mUDERhZORQUhp9FwOiyWmNujmNCV8YWWCg==", + "dev": true, + "hasShrinkwrap": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@cap-js/asyncapi": "^1.0.0", + "@cap-js/openapi": "^1.0.0", + "@sap/cds": "^8.3 || ^9", + "@sap/cds-mtxs": "^2 || ^3", + "@sap/hdi-deploy": "^5", + "express": "^4.22.1 || ^5", + "hdb": "^2.0.0", + "livereload-js": "^4.0.1", + "mustache": "^4.0.1", + "ws": "^8.4.2", + "xml-js": "^1.6.11", + "yaml": "^2" + }, + "bin": { + "cds": "bin/cds.js", + "cds-ts": "bin/cds-ts.js", + "cds-tsx": "bin/cds-tsx.js" + }, + "optionalDependencies": { + "@cap-js/sqlite": ">=1" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/asyncapi": { + "version": "1.0.3", + "integrity": "sha512-vZSWKAe+3qfvZDXV5SSFiObGWmqyS9MDyEADb5PLVT8kzO39qGaSDPv/GzI/gwvRfCayGAjU4ThiBKrFA7Gclg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/db-service": { + "version": "2.11.0", + "integrity": "sha512-sl33LcxZYAJgMCQZDw4lMGe4kWYq6685Xc6ze4qcoM+rd6aqiyVsSC6C7XH5yerXs7cVHhRC+Dgo8AsaapFzlQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "generic-pool": "^3.9.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/openapi": { + "version": "1.4.0", + "integrity": "sha512-/LRSwn4SDxAi3qKwl09zoOhEVGaPGlYOPz/0S3UBnaMJVvaLyPiKbbaOtOnrrgulUX5OXt+ujPIQznOsbTzuAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "pluralize": "^8.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/sqlite": { + "version": "2.4.0", + "integrity": "sha512-Ao+AzIN6BWHNpLbGxAzF79OezFNHzDG2srwiBABs0FYxIxEGkc2hg6ETo79pTTt66gcWtx7pWh/N9xk2M6SFBQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@cap-js/db-service": "^2.11.0", + "better-sqlite3": "^12.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8", + "sql.js": "^1.13.0" + }, + "peerDependenciesMeta": { + "sql.js": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@eslint/js": { + "version": "10.0.1", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds": { + "version": "9.9.1", + "integrity": "sha512-GqdsBsRkZThhpOyzj8ihf/jDmf/2zprZFgaun6ZymUw4/ahzjK/bbdd6eQ8txDuv88pnUl2HPFjvUVq3O/6hCA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/cds-compiler": "^6.4", + "@sap/cds-fiori": "^2", + "express": "^4.22.1 || ^5", + "yaml": "^2" + }, + "bin": { + "cds-deploy": "bin/deploy.js", + "cds-serve": "bin/serve.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@eslint/js": "^9 || ^10", + "tar": "^7.5.6" + }, + "peerDependenciesMeta": { + "tar": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-compiler": { + "version": "6.9.1", + "integrity": "sha512-j5C61t1mPhMW3vpD3LIRVn40DMiIF2XahOPeJIPjRpUiGMbQPdVreqAhiRHg39GYhSK6etlr5/MIx3a2ljtqHg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "bin": { + "cdsc": "bin/cdsc.js", + "cdshi": "bin/cdshi.js", + "cdsse": "bin/cdsse.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-fiori": { + "version": "2.3.0", + "integrity": "sha512-6oWov+DSpFrSTgxXR0dZhak6aZ/IVRZvaHERMi0EgSTzIJdlvZlpw3Kf18ePMcTrRrtEXwD4RIjKt8pbs0g2Hg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-mtxs": { + "version": "3.9.0", + "integrity": "sha512-U9H9NXQxlxSNwSD/6U59+Egn9LIE2SRdu8i5bZqEG2GB4xEU6csduy0kY4EWvi8XXD8onbFSgw4AA9SB4pN0Yg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/hdi-deploy": "^5" + }, + "bin": { + "cds-mtx": "bin/cds-mtx.js", + "cds-mtx-migrate": "bin/cds-mtx-migrate.js" + }, + "peerDependencies": { + "@sap/cds": ">=9" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi": { + "version": "4.8.0", + "integrity": "sha512-tkJmY2ffm6mt4/LFwRBihlQkMxNAXa3ngvRe2N/6+qLIsUNdrH/M03S5mkygXq56K+KoVVZYuradajCusMWwsw==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "async": "^3.2.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.5", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi-deploy": { + "version": "5.6.1", + "integrity": "sha512-+qQ7qwG8lko303L5yRj2dud/nDAVuVblV/mmzJT44oPbF0Nry18eD2LUS23hFeuxjRa7rYK5YKQ8ffGgWxVrYQ==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "@sap/hdi": "^4.8.0", + "@sap/xsenv": "^6.0.0", + "async": "^3.2.6", + "dotenv": "^16.4.5", + "handlebars": "^4.7.8", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=18.x" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.6", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/xsenv": { + "version": "6.2.0", + "integrity": "sha512-8jrsX1OAM3YUqGU+4deggqvkxrBrHAPYEllBX0YJfWNffgxSZKHG75bRd/RV6hxPwulPL0DeHfd2eYJMeY5gdw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE file", + "dependencies": { + "debug": "4.4.3", + "node-cache": "^5.1.2", + "verror": "1.10.1" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/accepts": { + "version": "2.0.0", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/assert-plus": { + "version": "1.0.0", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/async": { + "version": "3.2.6", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/base64-js": { + "version": "1.5.1", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/better-sqlite3": { + "version": "12.9.0", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/@sap/cds-dk/node_modules/bindings": { + "version": "1.5.0", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/bl": { + "version": "4.1.0", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/body-parser": { + "version": "2.2.2", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/braces": { + "version": "3.0.3", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/buffer": { + "version": "5.7.1", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/@sap/cds-dk/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/chownr": { + "version": "1.1.4", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/clone": { + "version": "2.1.2", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-disposition": { + "version": "1.1.0", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie-signature": { + "version": "1.2.2", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/core-util-is": { + "version": "1.0.2", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/decompress-response": { + "version": "6.0.0", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/deep-extend": { + "version": "0.6.0", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/detect-libc": { + "version": "2.1.2", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/dotenv": { + "version": "16.6.1", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sap/cds-dk/node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/end-of-stream": { + "version": "1.4.5", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-object-atoms": { + "version": "1.1.1", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/etag": { + "version": "1.8.1", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/expand-template": { + "version": "2.0.3", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/express": { + "version": "5.2.1", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/extsprintf": { + "version": "1.4.1", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/file-uri-to-path": { + "version": "1.0.0", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/fill-range": { + "version": "7.1.1", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/finalhandler": { + "version": "2.1.1", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/fresh": { + "version": "2.0.0", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/fs-constants": { + "version": "1.0.0", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/generic-pool": { + "version": "3.9.0", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/github-from-package": { + "version": "0.0.0", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/handlebars": { + "version": "4.7.9", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/hasown": { + "version": "2.0.3", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb": { + "version": "2.27.1", + "integrity": "sha512-xYL/W+fq2TyGHyzm8muolQnw8tdh4+2NQ8mQP2FpLSuhfJ8l0jQNSUZoAXic7NfMEan1Jvf8V1L4blwkgTc6+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "iconv-lite": "0.7.0" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "lz4-wasm-nodejs": "0.9.2" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb/node_modules/iconv-lite": { + "version": "0.7.0", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/iconv-lite": { + "version": "0.7.2", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/ieee754": { + "version": "1.2.1", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ini": { + "version": "1.3.8", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-number": { + "version": "7.0.0", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-promise": { + "version": "4.0.0", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/livereload-js": { + "version": "4.0.2", + "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/lz4-wasm-nodejs": { + "version": "0.9.2", + "integrity": "sha512-hSwgJPS98q/Oe/89Y1OxzeA/UdnASG8GvldRyKa7aZyoAFCC8VPRtViBSava7wWC66WocjUwBpWau2rEmyFPsw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/math-intrinsics": { + "version": "1.1.0", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/media-typer": { + "version": "1.1.0", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/merge-descriptors": { + "version": "2.0.0", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch": { + "version": "4.0.8", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-types": { + "version": "3.0.2", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/mimic-response": { + "version": "3.1.0", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/minimist": { + "version": "1.2.8", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/mkdirp-classic": { + "version": "0.5.3", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/mustache": { + "version": "4.2.0", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/@sap/cds-dk/node_modules/napi-build-utils": { + "version": "2.0.0", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/negotiator": { + "version": "1.0.0", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/neo-async": { + "version": "2.6.2", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/node-abi": { + "version": "3.92.0", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/node-cache": { + "version": "5.1.2", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/object-inspect": { + "version": "1.13.4", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/on-finished": { + "version": "2.4.1", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/once": { + "version": "1.4.0", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/@sap/cds-dk/node_modules/parseurl": { + "version": "1.3.3", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/path-to-regexp": { + "version": "8.4.2", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/pluralize": { + "version": "8.0.0", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sap/cds-dk/node_modules/prebuild-install": { + "version": "7.1.3", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/proxy-addr": { + "version": "2.0.7", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/pump": { + "version": "3.0.4", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/@sap/cds-dk/node_modules/qs": { + "version": "6.15.1", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/raw-body": { + "version": "3.0.2", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/rc": { + "version": "1.2.8", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/readable-stream": { + "version": "3.6.2", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sap/cds-dk/node_modules/router": { + "version": "2.2.0", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sap/cds-dk/node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/sax": { + "version": "1.6.0", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/semver": { + "version": "7.7.4", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/send": { + "version": "1.2.1", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/serve-static": { + "version": "2.2.1", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/side-channel": { + "version": "1.1.0", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-weakmap": { + "version": "1.0.2", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/simple-concat": { + "version": "1.0.1", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/simple-get": { + "version": "4.0.1", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/string_decoder": { + "version": "1.3.0", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/strip-json-comments": { + "version": "2.0.1", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-fs": { + "version": "2.1.4", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-stream": { + "version": "2.2.0", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/to-regex-range": { + "version": "5.0.1", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/tunnel-agent": { + "version": "0.6.0", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@sap/cds-dk/node_modules/type-is": { + "version": "2.0.1", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/uglify-js": { + "version": "3.19.3", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/util-deprecate": { + "version": "1.0.2", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/vary": { + "version": "1.1.2", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/verror": { + "version": "1.10.1", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/wordwrap": { + "version": "1.0.0", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/wrappy": { + "version": "1.0.2", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ws": { + "version": "8.20.0", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/xml-js": { + "version": "1.6.11", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/yaml": { + "version": "2.8.4", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/cds-feature-sap-document-ai/package.json b/cds-feature-sap-document-ai/package.json new file mode 100644 index 0000000..e2011fa --- /dev/null +++ b/cds-feature-sap-document-ai/package.json @@ -0,0 +1,9 @@ +{ + "name": "cds-feature-sap-document-ai-cds", + "version": "1.0.0", + "private": true, + "description": "CDS build dependencies for cds-feature-sap-document-ai. Pulled in by Maven (cds-maven-plugin npm goal) so a fresh `mvn install` is hermetic and does not require a globally installed @sap/cds-dk.", + "devDependencies": { + "@sap/cds-dk": "9.9.1" + } +} diff --git a/cds-feature-sap-document-ai/pom.xml b/cds-feature-sap-document-ai/pom.xml new file mode 100644 index 0000000..484cf19 --- /dev/null +++ b/cds-feature-sap-document-ai/pom.xml @@ -0,0 +1,155 @@ + + + 4.0.0 + + + com.sap.cds + cds-ai-root + ${revision} + + + cds-feature-sap-document-ai + jar + + CDS Feature SAP Document AI + SAP Document AI (Document Information Extraction) integration for CAP Java + + + + com.sap.cds + cds-services-api + + + + com.sap.cds + cds-services-utils + + + + com.sap.cds + cds4j-core + ${cds.services.version} + provided + + + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient5 + + + + + com.sap.cds + cds-services-impl + test + + + + org.awaitility + awaitility + 4.2.2 + test + + + + + ${project.artifactId} + + + com.sap.cds + cds-maven-plugin + + + cds.install-node + + install-node + + + + cds.npm-ci + + npm + + + ci + + + + cds.build + + cds + + + ./src/main/resources/cds/com.sap.cds/sap-document-ai + + build --for java --src ./ --dest ../../../../../../gen/srv + + + + + cds.generate + + generate + + + com.sap.cds.feature.documentai.generated.cds4j + ${project.basedir}/src/gen/srv/src/main/resources/edmx/csn.json + + sap.document.ai.** + + + + + + + + maven-clean-plugin + + + + ./ + + .flattened-pom.xml + + + + + + + auto-clean + + clean + + clean + + + + + + org.jacoco + jacoco-maven-plugin + + + **/feature/documentai/generated/** + + + + + jacoco-initialize + + prepare-agent + + + + jacoco-site-report + + report + + verify + + + + + + + diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java new file mode 100644 index 0000000..1917294 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java @@ -0,0 +1,179 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.configuration; + +import com.sap.cds.feature.documentai.handlers.DocumentSubmissionHandler; +import com.sap.cds.feature.documentai.handlers.ExtractionPollingHandler; +import com.sap.cds.feature.documentai.service.DefaultDocumentAiProcessingService; +import com.sap.cds.feature.documentai.service.DocumentAiProcessingService; +import com.sap.cds.feature.documentai.service.ExtractionServiceImpl; +import com.sap.cds.feature.documentai.service.client.DefaultDocumentAiClient; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.*; +import java.time.Duration; +import java.util.Optional; +import org.apache.hc.client5.http.classic.HttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * CDS plugin configuration that wires up all Document AI services and event handlers at runtime. + * + *

Implements {@link CdsRuntimeConfiguration} so it is picked up automatically by the CDS runtime + * via the Java {@code ServiceLoader} mechanism (declared in {@code + * META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration}). + * + *

Responsibilities: + * + *

    + *
  • Registers {@link ExtractionServiceImpl} as a CDS service. + *
  • Resolves the DIE service binding from the environment and builds an authenticated {@link + * DefaultDocumentAiClient} via the SAP Cloud SDK destination API. + *
  • Wires all dependencies into {@link ExtractionServiceImpl} and registers the {@link + * DocumentSubmissionHandler} and (when a binding is present) the {@link + * ExtractionPollingHandler}. + *
+ */ +public class DocumentAiServiceConfiguration implements CdsRuntimeConfiguration { + + private static final Logger logger = + LoggerFactory.getLogger(DocumentAiServiceConfiguration.class); + + private ExtractionServiceImpl extractionService; + + static { + OAuth2ServiceBindingDestinationLoader.registerPropertySupplier( + options -> + ServiceBindingUtils.matches( + options.getServiceBinding(), + DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL), + DefaultOAuth2PropertySupplier::new); + } + + /** + * Registers {@link ExtractionServiceImpl} as a CDS service so it is available in the service + * catalog for injection into event handlers. + */ + @Override + public void services(CdsRuntimeConfigurer configurer) { + extractionService = new ExtractionServiceImpl(); + configurer.service(extractionService); + } + + /** + * Resolves runtime dependencies and registers all plugin event handlers. + * + *

{@link DocumentSubmissionHandler} is always registered. {@link ExtractionPollingHandler} is + * only registered when a DIE service binding is found and a {@link DocumentAiClient} can be + * built; without a binding the plugin accepts extraction events but leaves jobs as {@code + * PENDING}. + */ + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + CdsRuntime runtime = configurer.getCdsRuntime(); + ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); + + // framework-managed dependency + PersistenceService persistenceService = + serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + // internal + DocumentAiClient documentAiClient = buildDocumentAi(runtime.getEnvironment()); + DocumentAiProcessingService documentAiProcessingService = + new DefaultDocumentAiProcessingService(documentAiClient); + + OutboxService outboxService = + serviceCatalog.getService(OutboxService.class, OutboxService.PERSISTENT_UNORDERED_NAME); + + if (outboxService == null) { + logger.warn( + "[sap-document-ai] Persistent outbox not available — polling scheduler disabled. Ensure cds.outbox.persistent is configured."); + } + + int intervalSeconds = + runtime + .getEnvironment() + .getProperty( + "cds.document-ai.polling.interval-seconds", + Integer.class, + ExtractionPollingHandler.DEFAULT_POLL_INTERVAL_SECONDS); + Duration pollDelay = Duration.ofSeconds(intervalSeconds); + + extractionService.init( + persistenceService, documentAiProcessingService, outboxService, pollDelay); + + configurer.eventHandler(new DocumentSubmissionHandler(extractionService)); + + // polling handler — only registered when a DIE binding is present + if (documentAiClient != null) { + configurer.eventHandler( + new ExtractionPollingHandler( + persistenceService, + extractionService, + documentAiClient, + outboxService, + runtime, + pollDelay)); + } + } + + /** + * Attempts to build a {@link DocumentAiClient} from the first DIE service binding found in the + * environment. + * + *

If no binding is present, or if the Cloud SDK destination cannot be constructed, {@code + * null} is returned and extraction is effectively disabled until a binding becomes available. + * + * @param environment the CDS runtime environment used to look up service bindings + * @return a configured {@link DocumentAiClient}, or {@code null} if unavailable + */ + static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { + Optional optionalBinding = + environment + .getServiceBindings() + .filter( + b -> + ServiceBindingUtils.matches( + b, DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL)) + .findFirst(); + + if (optionalBinding.isEmpty()) { + logger.warn("[sap-document-ai] No Document AI service binding found, extraction disabled."); + return null; + } + + ServiceBinding binding = optionalBinding.get(); + logger.info( + "[sap-document-ai] Using Document AI binding '{}', plan '{}'", + binding.getName().orElse("unknown"), + binding.getServicePlan().orElse("unknown")); + + try { + HttpDestination httpDestination = + ServiceBindingDestinationLoader.defaultLoaderChain() + .getDestination( + ServiceBindingDestinationOptions.forService(binding) + .onBehalfOf(OnBehalfOf.TECHNICAL_USER_CURRENT_TENANT) + .build()); + HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + logger.info( + "[sap-document-ai] Document AI destination created successfully, url={}", + httpDestination.getUri()); + return new DefaultDocumentAiClient(httpDestination, httpClient); + } catch (Exception e) { + logger.warn( + "[sap-document-ai] Failed to create Document AI destination, extraction disabled.", e); + return null; + } + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java new file mode 100644 index 0000000..2e2ea40 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java @@ -0,0 +1,70 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.handlers; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * CDS event handler that listens for {@code DocumentExtraction} events on any {@link + * ApplicationService} and delegates to {@link ExtractionService} to create and submit an extraction + * job. + * + *

The handler is intentionally service-name-agnostic ({@code @ServiceName(value = "*")}) so + * consumer applications can emit {@code DocumentExtraction} from their own CAP service without + * needing to couple to the plugin's internal service name. + */ +@ServiceName(value = "*", type = ApplicationService.class) +public class DocumentSubmissionHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(DocumentSubmissionHandler.class); + + private final ExtractionService extractionService; + + public DocumentSubmissionHandler(ExtractionService extractionService) { + this.extractionService = extractionService; + } + + /** + * Handles an incoming {@code DocumentExtraction} event. + * + *

Extracts the file metadata and content from the event context, calls {@link + * ExtractionService#triggerExtraction}, and logs a warning or error if the job could not be + * submitted immediately. + * + * @param context the CDS event context carrying the {@link DocumentExtraction} payload + */ + @On(event = DocumentExtractionContext.CDS_NAME) + public void onDocumentExtraction(DocumentExtractionContext context) { + DocumentExtraction event = context.getData(); + String tenantId = context.getUserInfo().getTenant(); + + logger.info( + "[sap-document-ai] DocumentExtraction event received, fileName={}", event.getFileName()); + + ExtractionResult result = + extractionService.triggerExtraction( + event.getFileName(), + event.getMimeType(), + event.getContent(), + event.getOptions(), + tenantId); + + if (result.status() == ExtractionResult.Status.FAILED) { + logger.error("[sap-document-ai] Extraction failed for fileName={}", event.getFileName()); + } else if (result.status() == ExtractionResult.Status.PENDING) { + logger.warn("[sap-document-ai] Document AI unavailable, left as PENDING"); + } + + context.setCompleted(); + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java new file mode 100644 index 0000000..0ba4a70 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java @@ -0,0 +1,201 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.handlers; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.outbox.OutboxMessage; +import com.sap.cds.services.outbox.OutboxMessageEventContext; +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.outbox.Schedule; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import java.time.Duration; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Outbox-driven handler that polls the DIE service for the status of all active extraction jobs. + * + *

Registered against the persistent unordered outbox service. On each invocation it: + * + *

    + *
  1. Queries all jobs in {@code SUBMITTED} or {@code RUNNING} status. + *
  2. For each job, calls {@link DocumentAiClient#getJobResult} and maps the DIE status to an + * {@link ExtractionStatus} transition. + *
  3. Persists the new status via {@link ExtractionService#updateExtractionResult}. + *
  4. When a job reaches {@code DONE}, emits a {@code DocumentExtractionResult} event on the + * {@code DocumentAiService} so consumer handlers can react. + *
  5. If jobs remain active, re-schedules itself via the outbox after {@link + * #DEFAULT_POLL_INTERVAL_SECONDS} seconds. + *
+ * + *

This self-rescheduling pattern means polling stops automatically once all jobs reach a + * terminal status ({@code DONE} or {@code FAILED}), avoiding unnecessary cycles. + * + *

The poll interval defaults to 3 seconds and can be overridden via the application property + * {@code cds.document-ai.polling.interval-seconds}. + */ +@ServiceName(value = ExtractionPollingHandler.OUTBOX_NAME, type = OutboxService.class) +public class ExtractionPollingHandler implements EventHandler { + + static final String OUTBOX_NAME = OutboxService.PERSISTENT_UNORDERED_NAME; + public static final String POLL_EVENT = "document-ai/poll-extraction-jobs"; + public static final String POLL_TASK_NAME = "document-ai-poll-extraction-jobs"; + public static final int DEFAULT_POLL_INTERVAL_SECONDS = 3; + + private static final Logger logger = LoggerFactory.getLogger(ExtractionPollingHandler.class); + + private final PersistenceService persistenceService; + private final ExtractionService extractionService; + private final DocumentAiClient documentAiClient; + private final OutboxService outboxService; + private final CdsRuntime runtime; + private final Duration pollDelay; + + /** + * @param persistenceService the CDS persistence service for querying active jobs + * @param extractionService the extraction service for updating job status + * @param documentAiClient the DIE HTTP client + * @param outboxService the persistent outbox used to reschedule polling cycles + * @param runtime the CDS runtime for service catalog lookups + * @param pollDelay the delay between successive poll cycles + */ + public ExtractionPollingHandler( + PersistenceService persistenceService, + ExtractionService extractionService, + DocumentAiClient documentAiClient, + OutboxService outboxService, + CdsRuntime runtime, + Duration pollDelay) { + this.persistenceService = persistenceService; + this.extractionService = extractionService; + this.documentAiClient = documentAiClient; + this.outboxService = outboxService; + this.runtime = runtime; + this.pollDelay = pollDelay; + } + + /** + * Outbox event handler that performs a single poll cycle across all active jobs. + * + * @param context the outbox message context; {@link OutboxMessageEventContext#setCompleted()} is + * called to acknowledge the message regardless of per-job errors + */ + @On(event = POLL_EVENT) + public void pollExtractionJobs(OutboxMessageEventContext context) { + List activeJobs = + persistenceService + .run( + Select.from(ExtractionJob_.class) + .where( + j -> + j.status() + .eq(ExtractionStatus.SUBMITTED.name()) + .or(j.status().eq(ExtractionStatus.RUNNING.name())))) + .listOf(ExtractionJob.class); + + logger.debug("[sap-document-ai] Polling {} active extraction job(s)", activeJobs.size()); + + if (activeJobs.isEmpty()) { + logger.debug("[sap-document-ai] No active jobs, polling stopped"); + context.setCompleted(); + return; + } + + for (ExtractionJob job : activeJobs) { + processJob(job); + } + + if (outboxService != null) { + outboxService.submit( + POLL_EVENT, + OutboxMessage.create(), + Schedule.create().taskName(POLL_TASK_NAME).after(pollDelay)); + } else { + logger.warn("[sap-document-ai] Outbox not available, next poll cycle will not be scheduled"); + } + + context.setCompleted(); + } + + private void processJob(ExtractionJob job) { + String jobId = job.getId(); + String dieJobId = job.getDocumentAiJobId(); + + if (dieJobId == null) { + logger.warn("[sap-document-ai] jobId={} has no DIE job ID, skipping poll", jobId); + return; + } + + try { + ExtractionData result = documentAiClient.getJobResult(dieJobId); + ExtractionStatus newStatus = mapDieStatus(result.dieStatus()); + + if (newStatus == null) { + logger.debug( + "[sap-document-ai] jobId={} DIE status={} — no transition yet", + jobId, + result.dieStatus()); + return; + } + + String extractionResult = newStatus == ExtractionStatus.DONE ? result.rawResult() : null; + + extractionService.updateExtractionResult(jobId, newStatus, dieJobId, extractionResult); + + if (newStatus == ExtractionStatus.DONE) { + logger.info( + "[sap-document-ai] Extraction complete for jobId={}, dieJobId={}", jobId, dieJobId); + emitExtractionCompleted(jobId, extractionResult); + } + + } catch (Exception e) { + logger.error( + "[sap-document-ai] Failed to poll/update jobId={}, dieJobId={}", jobId, dieJobId, e); + } + } + + private void emitExtractionCompleted(String jobId, String extractionResult) { + ApplicationService documentAiService = + runtime + .getServiceCatalog() + .getService(ApplicationService.class, DocumentAiService_.CDS_NAME); + if (documentAiService == null) { + logger.warn( + "[sap-document-ai] DocumentAiService not found in catalog, cannot emit result for jobId={}", + jobId); + return; + } + DocumentExtractionResult eventData = DocumentExtractionResult.create(); + eventData.setJobId(jobId); + eventData.setExtractionResult(extractionResult); + DocumentExtractionResultContext eventContext = DocumentExtractionResultContext.create(); + eventContext.setData(eventData); + documentAiService.emit(eventContext); + logger.info("[sap-document-ai] Emitted DocumentExtractionResult for jobId={}", jobId); + } + + private ExtractionStatus mapDieStatus(String dieStatus) { + return switch (dieStatus.toUpperCase()) { + case "RUNNING" -> ExtractionStatus.RUNNING; + case "DONE" -> ExtractionStatus.DONE; + case "FAILED" -> ExtractionStatus.FAILED; + default -> null; // PENDING or unknown — no transition + }; + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java new file mode 100644 index 0000000..ec4a73f --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java @@ -0,0 +1,42 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.feature.documentai.service.exceptions.DocumentAiException; +import com.sap.cds.feature.documentai.service.model.DocumentInput; + +/** + * Default implementation of {@link DocumentAiProcessingService}. + * + *

Delegates directly to {@link DocumentAiClient}. When no DIE service binding is configured, the + * configuration layer passes {@code null} as the client and {@link #isAvailable()} returns {@code + * false}, allowing the rest of the plugin to remain operational while queuing jobs as {@code + * PENDING}. + */ +public class DefaultDocumentAiProcessingService implements DocumentAiProcessingService { + + public static final String SAP_DOCUMENT_AI_SERVICE_LABEL = "sap-document-information-extraction"; + + private final DocumentAiClient documentAiClient; + + public DefaultDocumentAiProcessingService(DocumentAiClient documentAiClient) { + this.documentAiClient = documentAiClient; + } + + @Override + public String processDocument(String jobId, DocumentInput documentInput) { + try { + String documentAiJobId = documentAiClient.submitDocument(documentInput); + return documentAiJobId; + } catch (Exception e) { + throw new DocumentAiException.Processing("Failed to process document for jobId=" + jobId, e); + } + } + + @Override + public boolean isAvailable() { + return documentAiClient != null; + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java new file mode 100644 index 0000000..3972446 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java @@ -0,0 +1,34 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +import com.sap.cds.feature.documentai.service.model.DocumentInput; + +/** + * Abstraction over the Document AI (DIE) submission layer. + * + *

Decouples {@link com.sap.cds.feature.documentai.service.ExtractionServiceImpl} from the + * concrete HTTP client so the service can remain operational (returning {@code PENDING} jobs) when + * no DIE binding is configured. + */ +public interface DocumentAiProcessingService { + + /** + * Returns {@code true} if a DIE binding is available and document submission is possible. + * + * @return {@code true} when the underlying client is initialised, {@code false} otherwise + */ + boolean isAvailable(); + + /** + * Submits a document to the DIE service and returns the DIE-assigned job ID. + * + * @param jobId the internal extraction job ID, used for correlation in logs and exceptions + * @param documentInput the document content and metadata to submit + * @return the job ID assigned by the DIE service + * @throws com.sap.cds.feature.documentai.service.exceptions.DocumentAiException if submission or + * response parsing fails + */ + String processDocument(String jobId, DocumentInput documentInput); +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java new file mode 100644 index 0000000..4658717 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java @@ -0,0 +1,57 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +import com.sap.cds.feature.documentai.service.exceptions.IllegalStatusTransitionException; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.services.Service; +import java.io.InputStream; + +/** + * CDS service interface for managing document extraction jobs. + * + *

Handles the lifecycle of an extraction job from initial submission through status updates. + * Implementations are expected to persist job state and coordinate with the Document AI processing + * layer. + */ +public interface ExtractionService extends Service { + + String NAME = "ExtractionService"; + + /** + * Triggers a new document extraction job. + * + *

Creates a job record in {@code PENDING} status, then attempts to submit the document to the + * Document AI service. If the service is unavailable, the job remains {@code PENDING} for later + * retry. On successful submission the job transitions to {@code SUBMITTED} and polling is + * scheduled. + * + * @param fileName the original file name, forwarded to the DIE service + * @param mimeType the MIME type of the document content + * @param content the document byte stream + * @param options JSON options string passed to the DIE service; may be {@code null} + * @param tenantId the tenant under which the job is created + * @return an {@link ExtractionResult} describing the outcome and the internal job ID + * @throws IllegalStatusTransitionException if the resulting status update violates the allowed + * state machine + */ + ExtractionResult triggerExtraction( + String fileName, String mimeType, InputStream content, String options, String tenantId) + throws IllegalStatusTransitionException; + + /** + * Updates the status of an existing extraction job after a poll result from DIE. + * + * @param jobId the internal job ID + * @param status the new {@link ExtractionStatus} to apply + * @param dieJobId the DIE-side job ID to persist alongside the status update; may be {@code null} + * @param extractionResult the raw JSON result returned by DIE; only non-{@code null} when status + * is {@code DONE} + * @throws IllegalStatusTransitionException if the transition from the current status to {@code + * status} is not permitted + */ + void updateExtractionResult( + String jobId, ExtractionStatus status, String dieJobId, String extractionResult) + throws IllegalStatusTransitionException; +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java new file mode 100644 index 0000000..2216619 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java @@ -0,0 +1,222 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +import static com.sap.cds.feature.documentai.handlers.ExtractionPollingHandler.*; +import static com.sap.cds.feature.documentai.service.ExtractionStatus.*; + +import com.sap.cds.Result; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.service.exceptions.ConcurrentJobUpdateException; +import com.sap.cds.feature.documentai.service.exceptions.IllegalStatusTransitionException; +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.feature.documentai.service.model.ExtractionResult.Status; +import com.sap.cds.feature.documentai.service.utils.StatusTransitionValidator; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.ServiceDelegator; +import com.sap.cds.services.outbox.OutboxMessage; +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.outbox.Schedule; +import com.sap.cds.services.persistence.PersistenceService; +import java.io.InputStream; +import java.time.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default implementation of {@link ExtractionService}. + * + *

Orchestrates the full extraction lifecycle: + * + *

    + *
  1. Persists a new {@code ExtractionJob} in {@code PENDING} status. + *
  2. Delegates document submission to {@link DocumentAiProcessingService}. + *
  3. On success, advances the job to {@code SUBMITTED} and schedules a polling cycle via the + * persistent outbox. + *
  4. On failure, marks the job as {@code FAILED} and returns the appropriate result. + *
+ * + *

Status updates use an optimistic-lock pattern: the {@code UPDATE} query includes a {@code + * WHERE status = currentStatus} predicate. Zero rows affected raises {@link + * com.sap.cds.feature.documentai.service.exceptions.ConcurrentJobUpdateException}. + */ +public class ExtractionServiceImpl extends ServiceDelegator implements ExtractionService { + + private static final Logger logger = LoggerFactory.getLogger(ExtractionServiceImpl.class); + + private PersistenceService persistenceService; + private DocumentAiProcessingService documentAiProcessingService; + private OutboxService outboxService; + private Duration pollDelay; + + public ExtractionServiceImpl() { + super(NAME); + } + + /** + * Injects runtime dependencies after Spring/CDS wiring is complete. + * + *

Called from {@link + * com.sap.cds.feature.documentai.configuration.DocumentAiServiceConfiguration} once all dependent + * services are resolved from the service catalog. + * + * @param persistenceService the CDS persistence service for job CRUD operations + * @param documentAiProcessingService the processing service wrapping the DIE HTTP client + * @param outboxService the persistent outbox used to schedule polling; may be {@code null} if the + * outbox is not configured + * @param pollDelay the delay before the first poll cycle, read from {@code + * cds.document-ai.polling.interval-seconds} + */ + public void init( + PersistenceService persistenceService, + DocumentAiProcessingService documentAiProcessingService, + OutboxService outboxService, + Duration pollDelay) { + this.persistenceService = persistenceService; + this.documentAiProcessingService = documentAiProcessingService; + this.outboxService = outboxService; + this.pollDelay = pollDelay; + } + + @Override + public ExtractionResult triggerExtraction( + String fileName, String mimeType, InputStream content, String options, String tenantId) + throws IllegalStatusTransitionException { + logger.info( + "[sap-document-ai] Direct extraction triggered for fileName={}, tenantId={}", + fileName, + tenantId); + + String jobId = createExtractionJob(tenantId); + + if (!documentAiProcessingService.isAvailable()) { + logger.warn( + "[sap-document-ai] Document AI unavailable, job {} left as PENDING for retry", jobId); + return new ExtractionResult(jobId, Status.PENDING, null); + } + + DocumentInput documentInput = new DocumentInput(fileName, mimeType, content, options); + return performExtraction(jobId, fileName, documentInput, tenantId); + } + + @Override + public void updateExtractionResult( + String jobId, ExtractionStatus status, String dieJobId, String extractionResult) + throws IllegalStatusTransitionException { + updateExtractionJob(jobId, status, dieJobId, extractionResult); + } + + private ExtractionResult performExtraction( + String jobId, String fileName, DocumentInput documentInput, String tenantId) { + try { + String documentAiJobId = documentAiProcessingService.processDocument(jobId, documentInput); + updateExtractionJob(jobId, SUBMITTED, documentAiJobId, null); + schedulePolling(); + return new ExtractionResult(jobId, Status.SUCCESS, documentAiJobId); + } catch (ConcurrentJobUpdateException e) { + logger.warn( + "[sap-document-ai] Concurrent update on jobId={}, skipping status write — job already advanced", + jobId); + return new ExtractionResult(jobId, Status.SUCCESS, null); + } catch (IllegalStatusTransitionException e) { + logger.error("[sap-document-ai] Invalid state transition for jobId={}", jobId, e); + throw e; + } catch (Exception e) { + logger.error( + "[sap-document-ai] Processing failed for fileName={}, tenantId={}", + fileName, + tenantId, + e); + markJobAsFailed(jobId); + return new ExtractionResult(jobId, Status.FAILED, null); + } + } + + private void schedulePolling() { + if (outboxService == null) { + logger.warn("[sap-document-ai] Outbox not available, polling will not be scheduled"); + return; + } + outboxService.submit( + POLL_EVENT, + OutboxMessage.create(), + Schedule.create().taskName(POLL_TASK_NAME).after(pollDelay)); + logger.debug("[sap-document-ai] Poll schedule submitted"); + } + + private void markJobAsFailed(String jobId) { + try { + updateExtractionJob(jobId, FAILED, null, null); + } catch (Exception e) { + logger.error("[sap-document-ai] Failed to update status to FAILED for jobId={}", jobId, e); + } + } + + private String createExtractionJob(String tenantId) { + ExtractionJob job = ExtractionJob.create(); + job.setTenantId(tenantId); + job.setStatus(PENDING.name()); + + Result result = persistenceService.run(Insert.into(ExtractionJob_.class).entry(job)); + String jobId = result.single(ExtractionJob.class).getId(); + logger.info("[sap-document-ai] ExtractionJob created with status=PENDING, jobId={}", jobId); + return jobId; + } + + private void updateExtractionJob( + String jobId, ExtractionStatus status, String documentAiJobId, String extractionResult) { + Result current = persistenceService.run(Select.from(ExtractionJob_.class).byId(jobId)); + ExtractionStatus currentStatus = fromString(current.single(ExtractionJob.class).getStatus()); + + if (currentStatus.equals(status)) { + logger.debug( + "[sap-document-ai] ExtractionJob jobId={} already in status {}, skipping update", + jobId, + status); + return; + } + + if (!StatusTransitionValidator.isValid(currentStatus, status)) { + throw new IllegalStatusTransitionException( + "Invalid status transition from " + currentStatus + " to " + status); + } + + ExtractionJob extractionJob = ExtractionJob.create(); + extractionJob.setStatus(status.name()); + if (documentAiJobId != null) { + extractionJob.setDocumentAiJobId(documentAiJobId); + } + if (extractionResult != null) { + extractionJob.setExtractionResult(extractionResult); + } + + Result updateResult = + persistenceService.run( + Update.entity(ExtractionJob_.class) + .where( + j -> + j.get(ExtractionJob.ID) + .eq(jobId) + .and(j.get(ExtractionJob.STATUS).eq(currentStatus.name()))) + .entry(extractionJob)); + + if (updateResult.rowCount() == 0) { + String message = + "Concurrent update detected for jobId=" + jobId + ", expected status=" + currentStatus; + logger.warn("[sap-document-ai] {}", message); + throw new ConcurrentJobUpdateException(message); + } + + logger.info( + "[sap-document-ai] ExtractionJob jobId={} status updated from {} to {}{}", + jobId, + currentStatus, + status, + documentAiJobId != null ? " with documentAiJobId=" + documentAiJobId : ""); + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java new file mode 100644 index 0000000..cfd2322 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java @@ -0,0 +1,45 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +/** + * Lifecycle statuses for a document extraction job. + * + *

The allowed transitions are enforced by {@link + * com.sap.cds.feature.documentai.service.utils.StatusTransitionValidator}: + * + *

+ *   PENDING → SUBMITTED | FAILED
+ *   SUBMITTED → RUNNING | DONE | FAILED
+ *   RUNNING → DONE | FAILED
+ * 
+ */ +public enum ExtractionStatus { + /** Job created but not yet submitted to DIE (e.g. DIE service unavailable at submit time). */ + PENDING, + /** Document submitted to DIE; awaiting processing. */ + SUBMITTED, + /** DIE has started processing the document. */ + RUNNING, + /** DIE processing finished successfully; extraction result is available. */ + DONE, + /** Processing failed at any stage. */ + FAILED; + + /** + * Converts a persisted string value back to an {@link ExtractionStatus}. + * + * @param value the raw status string stored in the database + * @return the matching {@link ExtractionStatus} + * @throws IllegalArgumentException if {@code value} does not match any known status + */ + public static ExtractionStatus fromString(String value) { + try { + return ExtractionStatus.valueOf(value); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Unknown ExtractionStatus value in database: '" + value + "'", e); + } + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java new file mode 100644 index 0000000..21057fa --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java @@ -0,0 +1,162 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.documentai.service.exceptions.DocumentAiException; +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.io.IOException; +import java.net.URI; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default {@link DocumentAiClient} implementation that communicates with the DIE REST API over HTTP + * using the SAP Cloud SDK destination and Apache HttpClient 5. + * + *

Two operations are provided: + * + *

    + *
  • {@link #submitDocument} — POSTs a multipart request containing the document file and a JSON + * options body, then parses the DIE job ID from the response. + *
  • {@link #getJobResult} — GETs the current status and extracted values for a previously + * submitted DIE job. + *
+ * + *

All HTTP failures and unexpected response shapes are wrapped in the appropriate {@link + * com.sap.cds.feature.documentai.service.exceptions.DocumentAiException} subclass. + */ +public class DefaultDocumentAiClient implements DocumentAiClient { + + private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiClient.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String DOCUMENT_AI_API_PATH = "document-information-extraction/v1"; + public static final String DOCUMENT_JOBS = "/document/jobs"; + public static final String EXTRACTED_VALUES_TRUE = "?extractedValues=true"; + private final HttpDestination destination; + private final HttpClient httpClient; + + /** + * @param destination the pre-configured SAP Cloud SDK HTTP destination pointing to the DIE + * service base URL with OAuth2 credentials + * @param httpClient the Apache HttpClient 5 instance used for all HTTP calls + */ + public DefaultDocumentAiClient(HttpDestination destination, HttpClient httpClient) { + this.destination = destination; + this.httpClient = httpClient; + } + + @Override + public String submitDocument(DocumentInput documentInput) { + URI submitUri = buildUri(DOCUMENT_AI_API_PATH + DOCUMENT_JOBS); + HttpPost request = buildSubmitRequest(documentInput, submitUri); + String body = executeRequest(request, submitUri); + return extractJobId(body); + } + + @Override + public ExtractionData getJobResult(String dieJobId) { + URI uri = + buildUri(DOCUMENT_AI_API_PATH + DOCUMENT_JOBS + "/" + dieJobId + EXTRACTED_VALUES_TRUE); + logger.info("[sap-document-ai] Polling DIE for dieJobId={}", dieJobId); + HttpGet request = new HttpGet(uri); + String body = executeRequest(request, uri); + return parseJobResult(dieJobId, body); + } + + private URI buildUri(String path) { + String base = destination.getUri().toString(); + String prefix = base.endsWith("/") ? base : base + "/"; + return URI.create(prefix).resolve(path); + } + + private HttpPost buildSubmitRequest(DocumentInput documentInput, URI submitUri) { + logger.info( + "[sap-document-ai] Submitting document to DIE at url={}, fileName={}, mimeType={}", + submitUri, + documentInput.fileName(), + documentInput.mimeType()); + + ContentType contentType = + documentInput.mimeType() != null + ? ContentType.create(documentInput.mimeType()) + : ContentType.APPLICATION_OCTET_STREAM; + String options = documentInput.options(); + if (options == null) { + logger.warn( + "[sap-document-ai] No options provided for fileName={}, sending empty options to DIE", + documentInput.fileName()); + options = "{}"; + } + HttpPost request = new HttpPost(submitUri); + request.setEntity( + MultipartEntityBuilder.create() + .addBinaryBody("file", documentInput.content(), contentType, documentInput.fileName()) + .addTextBody("options", options, ContentType.APPLICATION_JSON) + .build()); + + return request; + } + + private String executeRequest(HttpUriRequestBase request, URI uri) { + try { + return httpClient.execute( + request, + response -> { + String body = EntityUtils.toString(response.getEntity()); + int statusCode = response.getCode(); + if (statusCode < 200 || statusCode >= 300) { + throw new DocumentAiException.Request(statusCode, body); + } + return body; + }); + } catch (IOException e) { + throw new DocumentAiException.Connectivity(uri.toString(), e); + } + } + + private String extractJobId(String body) { + try { + JsonNode json = objectMapper.readTree(body); + + if (!json.has("id")) { + throw new DocumentAiException.Processing("Unexpected DIE response. body=" + body, null); + } + + String jobId = json.get("id").asText(); + logger.info("[sap-document-ai] Document submitted successfully, DIE jobId={}", jobId); + return jobId; + + } catch (JsonProcessingException e) { + throw new DocumentAiException.Processing("Failed to parse DIE response", e); + } + } + + private ExtractionData parseJobResult(String dieJobId, String body) { + try { + JsonNode json = objectMapper.readTree(body); + String status = json.path("status").asText(); + if (status.isEmpty()) { + throw new DocumentAiException.Processing( + "DIE job response missing 'status' field for dieJobId=" + dieJobId + ". body=" + body, + null); + } + logger.debug("[sap-document-ai] DIE job dieJobId={} status={}", dieJobId, status); + return new ExtractionData(dieJobId, status, body); + } catch (JsonProcessingException e) { + throw new DocumentAiException.Processing("Failed to parse DIE job result response", e); + } + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java new file mode 100644 index 0000000..cd327c4 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java @@ -0,0 +1,36 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.client; + +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import com.sap.cds.feature.documentai.service.model.ExtractionData; + +/** + * Low-level HTTP client interface for the Document Information Extraction (DIE) service. + * + *

Abstracts the REST calls so that higher-level services and handlers are not coupled to the + * Apache HTTP client or SAP Cloud SDK destination APIs. + */ +public interface DocumentAiClient { + + /** + * Submits a document to the DIE service for extraction. + * + * @param documentInput the document content and metadata + * @return the DIE-assigned job ID for the submitted document + * @throws com.sap.cds.feature.documentai.service.exceptions.DocumentAiException if the HTTP call + * fails or the response cannot be parsed + */ + String submitDocument(DocumentInput documentInput); + + /** + * Polls the DIE service for the current status and result of a previously submitted job. + * + * @param dieJobId the job ID returned by {@link #submitDocument} + * @return an {@link ExtractionData} containing the DIE status and the raw result JSON + * @throws com.sap.cds.feature.documentai.service.exceptions.DocumentAiException if the HTTP call + * fails or the response cannot be parsed + */ + ExtractionData getJobResult(String dieJobId); +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java new file mode 100644 index 0000000..d58ea7e --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java @@ -0,0 +1,22 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.exceptions; + +/** + * Thrown when an optimistic-lock update of an extraction job detects that another thread or process + * has already advanced the job's status. + * + *

The update query in {@code ExtractionServiceImpl} uses a {@code WHERE status = currentStatus} + * predicate; zero rows affected means a concurrent writer got there first, and this exception is + * raised instead of silently overwriting that newer state. + */ +public class ConcurrentJobUpdateException extends RuntimeException { + + /** + * @param message description including the job ID and the expected status that was not found + */ + public ConcurrentJobUpdateException(String message) { + super(message); + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java new file mode 100644 index 0000000..38333fc --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java @@ -0,0 +1,87 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.exceptions; + +/** + * Base exception for all errors originating from interaction with the Document AI (DIE) service. + * + *

Concrete failure modes are represented by the three nested subclasses: + * + *

    + *
  • {@link Connectivity} — network-level failures (timeouts, DNS, etc.) + *
  • {@link Request} — non-2xx HTTP responses from DIE + *
  • {@link Processing} — unexpected or malformed response payloads + *
+ */ +public class DocumentAiException extends RuntimeException { + + /** + * @param message human-readable description of the failure + * @param cause the underlying exception, or {@code null} + */ + protected DocumentAiException(String message, Throwable cause) { + super(message, cause); + } + + /** + * @param message human-readable description of the failure + */ + protected DocumentAiException(String message) { + super(message); + } + + /** Raised when the HTTP connection to the DIE service cannot be established. */ + public static class Connectivity extends DocumentAiException { + + /** + * @param url the URL that was being contacted when the error occurred + * @param cause the underlying I/O exception + */ + public Connectivity(String url, Exception cause) { + super("Failed to connect to DIE at " + url, cause); + } + } + + /** Raised when DIE returns a non-2xx HTTP response. */ + public static class Request extends DocumentAiException { + private final int statusCode; + private final String responseBody; + + /** + * @param statusCode the HTTP status code returned by DIE + * @param responseBody the raw response body, included for diagnostics + */ + public Request(int statusCode, String responseBody) { + super("DIE request failed. Status=" + statusCode + ", body=" + responseBody); + this.statusCode = statusCode; + this.responseBody = responseBody; + } + + /** + * @return the HTTP status code returned by DIE + */ + public int getStatusCode() { + return statusCode; + } + + /** + * @return the raw response body returned by DIE + */ + public String getResponseBody() { + return responseBody; + } + } + + /** Raised when a DIE response cannot be parsed or is missing required fields. */ + public static class Processing extends DocumentAiException { + + /** + * @param message description of the parsing failure + * @param cause the underlying parse exception, or {@code null} + */ + public Processing(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java new file mode 100644 index 0000000..a23a5ca --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java @@ -0,0 +1,19 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.exceptions; + +/** + * Thrown when an attempt is made to transition an extraction job to a status that is not permitted + * by the state machine defined in {@link + * com.sap.cds.feature.documentai.service.utils.StatusTransitionValidator}. + */ +public class IllegalStatusTransitionException extends RuntimeException { + + /** + * @param message description including the current and target statuses + */ + public IllegalStatusTransitionException(String message) { + super(message); + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java new file mode 100644 index 0000000..603f508 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java @@ -0,0 +1,17 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.model; + +import java.io.InputStream; + +/** + * Immutable value object carrying the document data and metadata needed for a DIE submission. + * + * @param fileName the original file name sent to DIE + * @param mimeType the MIME type of the document (e.g. {@code application/pdf}) + * @param content the document byte stream; consumed exactly once during submission + * @param options JSON options string forwarded to DIE; {@code null} is treated as empty options + */ +public record DocumentInput( + String fileName, String mimeType, InputStream content, String options) {} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java new file mode 100644 index 0000000..0c81547 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java @@ -0,0 +1,15 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.model; + +/** + * Immutable value object holding the raw poll response returned by the DIE service for a job. + * + * @param dieJobId the job ID assigned by DIE + * @param dieStatus the status string as returned by DIE (e.g. {@code PENDING}, {@code RUNNING}, + * {@code DONE}, {@code FAILED}) + * @param rawResult the full JSON response body; only meaningful when {@code dieStatus} is {@code + * DONE} + */ +public record ExtractionData(String dieJobId, String dieStatus, String rawResult) {} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java new file mode 100644 index 0000000..6fd5197 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java @@ -0,0 +1,27 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.model; + +/** + * Immutable value object returned by {@link + * com.sap.cds.feature.documentai.service.ExtractionService#triggerExtraction} to convey the + * immediate outcome of a submission attempt. + * + * @param internalJobId the plugin-managed job ID created in the database + * @param status the outcome of the submission attempt (see {@link Status}) + * @param documentAiJobId the DIE-assigned job ID, or {@code null} if the document was not yet + * submitted (status {@code PENDING} or {@code FAILED}) + */ +public record ExtractionResult(String internalJobId, Status status, String documentAiJobId) { + + /** Immediate outcome of a {@code triggerExtraction} call. */ + public enum Status { + /** Document submitted to DIE successfully. */ + SUCCESS, + /** DIE was unavailable; job is queued for retry via the polling scheduler. */ + PENDING, + /** Submission failed with an unrecoverable error. */ + FAILED + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java new file mode 100644 index 0000000..febe1fa --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java @@ -0,0 +1,45 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.utils; + +import static com.sap.cds.feature.documentai.service.ExtractionStatus.*; + +import com.sap.cds.feature.documentai.service.ExtractionStatus; + +/** + * Utility class that enforces the allowed state-machine transitions for {@link ExtractionStatus}. + * + *

Permitted transitions: + * + *

+ *   PENDING   → SUBMITTED | FAILED
+ *   SUBMITTED → RUNNING | DONE | FAILED
+ *   RUNNING   → DONE | FAILED
+ *   DONE / FAILED → (terminal, no further transitions)
+ * 
+ * + * Same-status transitions are always considered valid (idempotent updates). + */ +public class StatusTransitionValidator { + + private StatusTransitionValidator() {} + + /** + * Returns {@code true} if transitioning from {@code current} to {@code next} is permitted. + * + * @param current the status the job is currently in + * @param next the desired target status + * @return {@code true} if the transition is allowed, {@code false} otherwise + */ + public static boolean isValid(ExtractionStatus current, ExtractionStatus next) { + if (current.equals(next)) return true; // idempotent + + return switch (current) { + case PENDING -> SUBMITTED.equals(next) || FAILED.equals(next); + case SUBMITTED -> RUNNING.equals(next) || DONE.equals(next) || FAILED.equals(next); + case RUNNING -> DONE.equals(next) || FAILED.equals(next); + default -> false; + }; + } +} diff --git a/cds-feature-sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/cds-feature-sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..4e66c37 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.feature.documentai.configuration.DocumentAiServiceConfiguration diff --git a/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds new file mode 100644 index 0000000..ee2e026 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds @@ -0,0 +1,16 @@ +namespace sap.document.ai; + +service DocumentAiService { + event DocumentExtraction { + fileName : String; + mimeType : String @Core.IsMediaType; + content : LargeBinary @Core.MediaType: mimeType; + options : LargeString; + } + + event DocumentExtractionResult { + jobId : String; + documentAiJobId : String; + extractionResult : LargeString; + } +} diff --git a/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds new file mode 100644 index 0000000..bfc75af --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -0,0 +1,13 @@ +namespace sap.document.ai; + +using { + cuid, + managed +} from '@sap/cds/common'; + +entity ExtractionJob : cuid, managed { + status : String; + tenantId : String; + documentAiJobId : String; + extractionResult : LargeString; +} diff --git a/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds new file mode 100644 index 0000000..0e60ba7 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds @@ -0,0 +1,2 @@ +using from './extraction-job'; +using from './document-ai-service'; diff --git a/cds-feature-sap-document-ai/src/main/resources/spotbugs-exclusion-filter.xml b/cds-feature-sap-document-ai/src/main/resources/spotbugs-exclusion-filter.xml new file mode 100644 index 0000000..95d797b --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/spotbugs-exclusion-filter.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java new file mode 100644 index 0000000..fbfade8 --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java @@ -0,0 +1,111 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.documentai.handlers.DocumentSubmissionHandler; +import com.sap.cds.feature.documentai.service.DefaultDocumentAiProcessingService; +import com.sap.cds.feature.documentai.service.ExtractionServiceImpl; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.services.Service; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentAiServiceConfigurationTest { + + @Mock CdsRuntimeConfigurer configurer; + @Mock CdsRuntime cdsRuntime; + @Mock ServiceCatalog serviceCatalog; + @Mock PersistenceService persistenceService; + @Mock CdsEnvironment environment; + + DocumentAiServiceConfiguration registration; + + @BeforeEach + void setUp() { + registration = new DocumentAiServiceConfiguration(); + } + + @Test + void servicesRegistersExtractionService() { + registration.services(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Service.class); + verify(configurer).service(captor.capture()); + assertThat(captor.getValue()).isInstanceOf(ExtractionServiceImpl.class); + } + + @Test + void eventHandlersRegistersDocumentSubmissionHandler() { + when(configurer.getCdsRuntime()).thenReturn(cdsRuntime); + when(cdsRuntime.getServiceCatalog()).thenReturn(serviceCatalog); + when(cdsRuntime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + when(environment.getProperty( + eq("cds.document-ai.polling.interval-seconds"), eq(Integer.class), any())) + .thenReturn(3); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); + + registration.services(configurer); + registration.eventHandlers(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); + verify(configurer, times(1)).eventHandler(captor.capture()); + + assertThat(captor.getValue()).isInstanceOf(DocumentSubmissionHandler.class); + } + + @Test + void buildDocumentAi_noBindingFound_returnsNull() { + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + + DocumentAiClient result = DocumentAiServiceConfiguration.buildDocumentAi(environment); + + assertThat(result).isNull(); + } + + @Test + void buildDocumentAi_bindingFound_destinationCreationFails_returnsNull() { + ServiceBinding binding = mock(ServiceBinding.class); + when(environment.getServiceBindings()).thenReturn(Stream.of(binding)); + + try (var utils = mockStatic(ServiceBindingUtils.class); + var loader = mockStatic(ServiceBindingDestinationLoader.class)) { + utils + .when( + () -> + ServiceBindingUtils.matches( + any(), eq(DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL))) + .thenReturn(true); + + ServiceBindingDestinationLoader loaderMock = mock(ServiceBindingDestinationLoader.class); + loader.when(ServiceBindingDestinationLoader::defaultLoaderChain).thenReturn(loaderMock); + when(loaderMock.getDestination(any())).thenThrow(new RuntimeException("destination fail")); + + DocumentAiClient result = DocumentAiServiceConfiguration.buildDocumentAi(environment); + + assertThat(result).isNull(); + } + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java new file mode 100644 index 0000000..90be694 --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java @@ -0,0 +1,90 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.handlers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.services.request.UserInfo; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentSubmissionHandlerTest { + + private static final String TENANT_ID = "tenant-1"; + private static final String FILE_NAME = "invoice.pdf"; + private static final String MIME_TYPE = "application/pdf"; + + @Mock ExtractionService extractionService; + @Mock DocumentExtractionContext eventContext; + @Mock UserInfo userInfo; + + DocumentSubmissionHandler handler; + + @BeforeEach + void setUp() { + handler = new DocumentSubmissionHandler(extractionService); + } + + private DocumentExtraction createEvent() { + DocumentExtraction event = DocumentExtraction.create(); + event.setFileName(FILE_NAME); + event.setMimeType(MIME_TYPE); + event.setContent(new ByteArrayInputStream("pdf-bytes".getBytes())); + return event; + } + + @Test + void onDocumentExtraction_triggersExtraction() { + when(eventContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(eventContext.getData()).thenReturn(createEvent()); + when(extractionService.triggerExtraction(any(), any(), any(), any(), any())) + .thenReturn( + new ExtractionResult("job-123", ExtractionResult.Status.SUCCESS, "dai-job-456")); + + handler.onDocumentExtraction(eventContext); + + verify(extractionService) + .triggerExtraction( + eq(FILE_NAME), eq(MIME_TYPE), any(InputStream.class), any(), eq(TENANT_ID)); + } + + @Test + void onDocumentExtraction_logsPendingWhenServiceUnavailable() { + when(eventContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(eventContext.getData()).thenReturn(createEvent()); + when(extractionService.triggerExtraction(any(), any(), any(), any(), any())) + .thenReturn(new ExtractionResult(null, ExtractionResult.Status.PENDING, null)); + + handler.onDocumentExtraction(eventContext); + + verify(extractionService).triggerExtraction(any(), any(), any(), any(), any()); + } + + @Test + void onDocumentExtraction_logsFailedWhenExtractionFails() { + when(eventContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(eventContext.getData()).thenReturn(createEvent()); + when(extractionService.triggerExtraction(any(), any(), any(), any(), any())) + .thenReturn(new ExtractionResult("job-123", ExtractionResult.Status.FAILED, null)); + + handler.onDocumentExtraction(eventContext); + + verify(extractionService).triggerExtraction(any(), any(), any(), any(), any()); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java new file mode 100644 index 0000000..610dc8b --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java @@ -0,0 +1,185 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.handlers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.sap.cds.Result; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.outbox.OutboxMessageEventContext; +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.outbox.Schedule; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ExtractionPollingHandlerTest { + + private static final String JOB_ID = "job-123"; + private static final String DIE_JOB_ID = "die-job-456"; + private static final String RAW_RESULT = "{\"extraction\":{}}"; + + @Mock PersistenceService persistenceService; + @Mock ExtractionService extractionService; + @Mock DocumentAiClient documentAiClient; + @Mock OutboxService outboxService; + @Mock CdsRuntime runtime; + @Mock ServiceCatalog serviceCatalog; + @Mock ApplicationService documentAiService; + @Mock OutboxMessageEventContext context; + @Mock Result queryResult; + + ExtractionPollingHandler handler; + + @BeforeEach + void setUp() { + handler = + new ExtractionPollingHandler( + persistenceService, + extractionService, + documentAiClient, + outboxService, + runtime, + Duration.ofSeconds(ExtractionPollingHandler.DEFAULT_POLL_INTERVAL_SECONDS)); + } + + private void mockEmit() { + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(serviceCatalog.getService(ApplicationService.class, DocumentAiService_.CDS_NAME)) + .thenReturn(documentAiService); + } + + @Test + void pollStopsAndSetsCompletedWhenNoActiveJobs() { + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of()); + + handler.pollExtractionJobs(context); + + verify(outboxService, never()).submit(any(), any(), any(Schedule.class)); + verify(context).setCompleted(); + } + + @Test + void pollReschedulesWhenActiveJobsExist() { + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); + + handler.pollExtractionJobs(context); + + verify(outboxService) + .submit(eq(ExtractionPollingHandler.POLL_EVENT), any(), any(Schedule.class)); + verify(context).setCompleted(); + } + + @Test + void pollSkipsJobWithNoDieJobId() { + mockActiveJob(null); + + handler.pollExtractionJobs(context); + + verify(documentAiClient, never()).getJobResult(any()); + } + + @Test + void pollDoesNotUpdateStatusWhenDieReturnsPending() { + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "PENDING", null)); + + handler.pollExtractionJobs(context); + + verify(extractionService, never()).updateExtractionResult(any(), any(), any(), any()); + } + + @Test + void pollUpdatesStatusToRunningWithoutEmittingEvent() { + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); + + handler.pollExtractionJobs(context); + + verify(extractionService) + .updateExtractionResult(JOB_ID, ExtractionStatus.RUNNING, DIE_JOB_ID, null); + verify(documentAiService, never()).emit(any()); + } + + @Test + void pollUpdatesStatusToFailedWithoutEmittingEvent() { + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "FAILED", null)); + + handler.pollExtractionJobs(context); + + verify(extractionService) + .updateExtractionResult(JOB_ID, ExtractionStatus.FAILED, DIE_JOB_ID, null); + verify(documentAiService, never()).emit(any()); + } + + @Test + void pollUpdatesStatusToDoneAndEmitsEvent() { + mockEmit(); + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "DONE", RAW_RESULT)); + + handler.pollExtractionJobs(context); + + verify(extractionService) + .updateExtractionResult(JOB_ID, ExtractionStatus.DONE, DIE_JOB_ID, RAW_RESULT); + verify(documentAiService).emit(any()); + } + + @Test + void pollContinuesToNextJobWhenOneThrows() { + ExtractionJob failingJob = ExtractionJob.create(); + failingJob.setId("job-fail"); + failingJob.setDocumentAiJobId("die-fail"); + + ExtractionJob goodJob = ExtractionJob.create(); + goodJob.setId(JOB_ID); + goodJob.setDocumentAiJobId(DIE_JOB_ID); + + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of(failingJob, goodJob)); + + when(documentAiClient.getJobResult("die-fail")).thenThrow(new RuntimeException("timeout")); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); + + handler.pollExtractionJobs(context); + + verify(extractionService) + .updateExtractionResult(JOB_ID, ExtractionStatus.RUNNING, DIE_JOB_ID, null); + verify(context).setCompleted(); + } + + private void mockActiveJob(String dieJobId) { + ExtractionJob job = ExtractionJob.create(); + job.setId(JOB_ID); + job.setDocumentAiJobId(dieJobId); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of(job)); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java new file mode 100644 index 0000000..212a68c --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java @@ -0,0 +1,70 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +import static org.mockito.ArgumentMatchers.any; + +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.feature.documentai.service.exceptions.DocumentAiException; +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/* + * Temporary tests until the real implementation is done + */ +class DefaultDocumentAiProcessingServiceTest { + + public static final String TEST_PDF = "test.pdf"; + public static final String CONTENT_TYPE = "application/pdf"; + public static final String TEST_CONTENT = "test"; + public static final String JOB_1 = "job-1"; + public static final String MOCK_RESULT = "mock-result"; + DocumentAiClient documentAiClient; + DefaultDocumentAiProcessingService service; + DocumentInput documentInput; + + @BeforeEach + void setUp() { + documentAiClient = Mockito.mock(DocumentAiClient.class); + Mockito.when(documentAiClient.submitDocument(any())).thenReturn(MOCK_RESULT); + service = new DefaultDocumentAiProcessingService(documentAiClient); + documentInput = + new DocumentInput( + TEST_PDF, + CONTENT_TYPE, + new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8)), + null); + } + + // ----- isAvailable() ------- + @Test + void isAvailableReturnsTrueWhenClientPresent() { + Assertions.assertThat(service.isAvailable()).isTrue(); + } + + @Test + void isAvailableReturnsFalseWhenClientNull() { + Assertions.assertThat(new DefaultDocumentAiProcessingService(null).isAvailable()).isFalse(); + } + + // ------- processDocument() ------- + @Test + void processDocumentCompletesWithoutException() { + Assertions.assertThatCode(() -> service.processDocument(JOB_1, documentInput)) + .doesNotThrowAnyException(); + } + + @Test + void processDocumentThrowsWhenSubmitDocumentFails() { + Mockito.when(documentAiClient.submitDocument(any())) + .thenThrow(new RuntimeException("submit failed")); + Assertions.assertThatThrownBy(() -> service.processDocument(JOB_1, documentInput)) + .isInstanceOf(DocumentAiException.Processing.class); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java new file mode 100644 index 0000000..3f05fad --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java @@ -0,0 +1,216 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +import static com.sap.cds.feature.documentai.service.ExtractionStatus.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +import com.sap.cds.Result; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.service.exceptions.IllegalStatusTransitionException; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.ql.cqn.CqnInsert; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.outbox.Schedule; +import com.sap.cds.services.persistence.PersistenceService; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ExtractionServiceImplTest { + + static final String TENANT_1 = "tenant-1"; + static final String DIE_JOB_ID = "die-job-123"; + static final String TEST_PDF = "test.pdf"; + static final String CONTENT_TYPE = "application/pdf"; + static final String TEST_CONTENT = "test-content"; + + @Mock PersistenceService persistenceService; + @Mock DocumentAiProcessingService documentAiProcessingService; + @Mock OutboxService outboxService; + @Mock Result insertResult; + + ExtractionServiceImpl extractionService; + + @BeforeEach + void setUp() { + when(documentAiProcessingService.isAvailable()).thenReturn(true); + extractionService = new ExtractionServiceImpl(); + extractionService.init( + persistenceService, documentAiProcessingService, outboxService, Duration.ofSeconds(3)); + } + + @Test + void triggerExtractionCreatesJobAsPendingWhenServiceUnavailable() { + mockInsertDatabaseCalls(); + when(documentAiProcessingService.isAvailable()).thenReturn(false); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); + assertThat(result.internalJobId()).isNotNull(); + verify(persistenceService).run(any(CqnInsert.class)); + } + + @Test + void triggerExtractionCreatesJobWithCorrectFields() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + var insertCaptor = org.mockito.ArgumentCaptor.forClass(CqnInsert.class); + verify(persistenceService, atLeastOnce()).run(insertCaptor.capture()); + ExtractionJob inserted = + com.sap.cds.Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); + assertThat(inserted.getTenantId()).isEqualTo(TENANT_1); + assertThat(inserted.getStatus()).isEqualTo(PENDING.name()); + } + + @Test + void triggerExtractionSubmitsDocumentAndUpdatesStatusToSubmitted() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); + assertThat(result.documentAiJobId()).isEqualTo(DIE_JOB_ID); + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); + verify(outboxService).submit(any(), any(), any(Schedule.class)); + } + + @Test + void triggerExtractionDoesNotThrowWhenOutboxIsNull() { + extractionService.init( + persistenceService, documentAiProcessingService, null, Duration.ofSeconds(3)); + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); + } + + @Test + void triggerExtractionMarksJobFailedOnProcessingError() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + doThrow(new RuntimeException("simulated failure")) + .when(documentAiProcessingService) + .processDocument(any(), any()); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.FAILED); + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); + } + + @Test + void triggerExtractionReturnSuccessOnConcurrentUpdate() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + Result zeroRowResult = mock(Result.class); + when(zeroRowResult.rowCount()).thenReturn(0L); + when(persistenceService.run(any(CqnUpdate.class))).thenReturn(zeroRowResult); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); + } + + @Test + void triggerExtractionThrowsOnInvalidStatusTransition() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + doThrow(new IllegalStatusTransitionException("invalid transition")) + .when(documentAiProcessingService) + .processDocument(any(), any()); + + assertThrows( + IllegalStatusTransitionException.class, + () -> + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1)); + + verify(persistenceService, never()).run(any(CqnUpdate.class)); + } + + @Test + void updateStatusWithSameStateSkipsUpdate() { + mockInsertDatabaseCalls(); + Result statusResult = resultWithJobStatus(SUBMITTED); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + verify(persistenceService, never()).run(any(CqnUpdate.class)); + } + + private InputStream contentStream() { + return new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8)); + } + + private void mockInsertDatabaseCalls() { + ExtractionJob createdJob = ExtractionJob.create(); + createdJob.setId("test-job-id"); + lenient().when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); + lenient().when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); + } + + private void mockAllDatabaseCalls() { + mockInsertDatabaseCalls(); + Result updateResult = mock(Result.class); + lenient().when(updateResult.rowCount()).thenReturn(1L); + lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(updateResult); + } + + private Result resultWithJobStatus(ExtractionStatus status) { + ExtractionJob job = ExtractionJob.create(); + job.setStatus(status.name()); + Result result = mock(Result.class); + lenient().when(result.single(ExtractionJob.class)).thenReturn(job); + lenient().when(result.first(ExtractionJob.class)).thenReturn(Optional.of(job)); + return result; + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java new file mode 100644 index 0000000..9a2226c --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java @@ -0,0 +1,200 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.documentai.service.exceptions.DocumentAiException; +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DefaultDocumentAiClientTest { + + private static final String BASE_URL = "https://example.com/"; + private static final String JOB_ID = "job-abc-123"; + + @Mock HttpDestination destination; + @Mock HttpClient httpClient; + @Mock ClassicHttpResponse response; + @Mock HttpEntity entity; + + DefaultDocumentAiClient client; + DocumentInput documentInput; + + @BeforeEach + void setUp() { + client = new DefaultDocumentAiClient(destination, httpClient); + documentInput = + new DocumentInput( + "invoice.pdf", + "application/pdf", + new ByteArrayInputStream("pdf-bytes".getBytes()), + null); + when(destination.getUri()).thenReturn(URI.create(BASE_URL)); + } + + @Test + void submitDocumentReturnsJobIdOnSuccess() throws IOException { + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\"}"); + + String result = client.submitDocument(documentInput); + + assertThat(result).isEqualTo(JOB_ID); + } + + @Test + void submitDocumentThrowsRequestExceptionOnNon2xxResponse() throws IOException { + mockHttpResponse(400, "Bad Request"); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(DocumentAiException.Request.class) + .hasMessageContaining("400"); + } + + @Test + void submitDocumentThrowsConnectivityExceptionOnIoFailure() throws IOException { + when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) + .thenThrow(new IOException("timeout")); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(DocumentAiException.Connectivity.class) + .hasMessageContaining(BASE_URL); + } + + @Test + void submitDocumentThrowsWhenResponseHasNoIdField() throws IOException { + mockHttpResponse(200, "{\"status\":\"ok\"}"); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(DocumentAiException.Processing.class) + .hasMessageContaining("Unexpected DIE response"); + } + + @Test + void submitDocumentThrowsWhenResponseIsNotValidJson() throws IOException { + mockHttpResponse(200, "not-json{{{{"); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(DocumentAiException.Processing.class) + .hasMessageContaining("Failed to parse DIE response"); + } + + @Test + void getJobResultReturnsStatusAndRawBody() throws IOException { + String responseBody = "{\"id\":\"" + JOB_ID + "\",\"status\":\"DONE\",\"extraction\":{}}"; + mockHttpResponse(200, responseBody); + + ExtractionData result = client.getJobResult(JOB_ID); + + assertThat(result.dieJobId()).isEqualTo(JOB_ID); + assertThat(result.dieStatus()).isEqualTo("DONE"); + assertThat(result.rawResult()).isEqualTo(responseBody); + } + + @Test + void getJobResultThrowsWhenStatusFieldMissing() throws IOException { + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\",\"extraction\":{}}"); + + assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + .isInstanceOf(DocumentAiException.Processing.class) + .hasMessageContaining("missing 'status' field"); + } + + @Test + void getJobResultThrowsRequestExceptionOnNon2xxResponse() throws IOException { + mockHttpResponse(404, "Not Found"); + + assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + .isInstanceOf(DocumentAiException.Request.class) + .hasMessageContaining("404"); + } + + @Test + void getJobResultThrowsConnectivityExceptionOnIoFailure() throws IOException { + when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) + .thenThrow(new IOException("timeout")); + + assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + .isInstanceOf(DocumentAiException.Connectivity.class); + } + + @Test + void getJobResultThrowsWhenResponseIsNotValidJson() throws IOException { + mockHttpResponse(200, "not-json{{{{"); + + assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + .isInstanceOf(DocumentAiException.Processing.class) + .hasMessageContaining("Failed to parse DIE job result response"); + } + + @Test + void submitDocumentUsesOctetStreamWhenMimeTypeIsNull() throws IOException { + documentInput = + new DocumentInput("invoice.pdf", null, new ByteArrayInputStream("bytes".getBytes()), null); + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\"}"); + + String result = client.submitDocument(documentInput); + + assertThat(result).isEqualTo(JOB_ID); + } + + @Test + @SuppressWarnings("unchecked") + void submitDocumentUsesEmptyJsonWhenOptionsIsNull() throws IOException { + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpPost.class); + when(httpClient.execute(requestCaptor.capture(), any(HttpClientResponseHandler.class))) + .thenAnswer( + invocation -> { + HttpClientResponseHandler handler = invocation.getArgument(1); + when(response.getCode()).thenReturn(200); + when(response.getEntity()).thenReturn(entity); + when(entity.getContent()) + .thenReturn(new ByteArrayInputStream(("{\"id\":\"" + JOB_ID + "\"}").getBytes())); + when(entity.getContentLength()).thenReturn(-1L); + return handler.handleResponse(response); + }); + + client.submitDocument(documentInput); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + requestCaptor.getValue().getEntity().writeTo(buffer); + String requestBody = buffer.toString(); + assertThat(requestBody).contains("{}").contains("options"); + } + + @SuppressWarnings("unchecked") + private void mockHttpResponse(int statusCode, String body) throws IOException { + when(response.getCode()).thenReturn(statusCode); + when(response.getEntity()).thenReturn(entity); + when(entity.getContent()).thenReturn(new ByteArrayInputStream(body.getBytes())); + when(entity.getContentLength()).thenReturn(-1L); + when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) + .thenAnswer( + invocation -> { + HttpClientResponseHandler handler = invocation.getArgument(1); + return handler.handleResponse(response); + }); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java new file mode 100644 index 0000000..61a818f --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java @@ -0,0 +1,58 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.exceptions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class ExceptionsTest { + + @Test + void documentAiConnectivityExceptionContainsUrlAndCause() { + IOException cause = new IOException("connection refused"); + String url = "https://example.com/die"; + DocumentAiException.Connectivity ex = new DocumentAiException.Connectivity(url, cause); + + assertThat(ex.getMessage()).contains(url); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void documentAiProcessingExceptionContainsMessageAndCause() { + RuntimeException cause = new RuntimeException("timeout"); + String message = "Failed to process jobId=123"; + DocumentAiException.Processing ex = new DocumentAiException.Processing(message, cause); + + assertThat(ex.getMessage()).isEqualTo(message); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void documentAiRequestExceptionContainsStatusCodeAndBody() { + String badRequest = "Bad Request"; + DocumentAiException.Request ex = new DocumentAiException.Request(400, badRequest); + + assertThat(ex.getStatusCode()).isEqualTo(400); + assertThat(ex.getResponseBody()).isEqualTo(badRequest); + assertThat(ex.getMessage()).contains("400").contains(badRequest); + } + + @Test + void illegalStatusTransitionExceptionContainsMessage() { + String message = "Invalid transition from PENDING to COMPLETED"; + IllegalStatusTransitionException ex = new IllegalStatusTransitionException(message); + + assertThat(ex.getMessage()).isEqualTo(message); + } + + @Test + void concurrentJobUpdateExceptionContainsMessage() { + String message = "Concurrent update detected for jobId=abc"; + ConcurrentJobUpdateException ex = new ConcurrentJobUpdateException(message); + + assertThat(ex.getMessage()).isEqualTo(message); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java new file mode 100644 index 0000000..7e3ff9d --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java @@ -0,0 +1,67 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.utils; + +import static com.sap.cds.feature.documentai.service.ExtractionStatus.*; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class StatusTransitionValidatorTest { + + @Test + void pendingToSubmittedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, SUBMITTED)).isTrue(); + } + + @Test + void pendingToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, FAILED)).isTrue(); + } + + @Test + void submittedToRunningIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, RUNNING)).isTrue(); + } + + @Test + void submittedToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, FAILED)).isTrue(); + } + + @Test + void submittedToDoneIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, DONE)).isTrue(); + } + + @Test + void runningToDoneIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, DONE)).isTrue(); + } + + @Test + void runningToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, FAILED)).isTrue(); + } + + @Test + void pendingToDoneIsInvalid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, DONE)).isFalse(); + } + + @Test + void runningToPendingIsInvalid() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, PENDING)).isFalse(); + } + + @Test + void doneToRunningIsInvalid() { + Assertions.assertThat(StatusTransitionValidator.isValid(DONE, RUNNING)).isFalse(); + } + + @Test + void sameTransitionIsIdempotent() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, RUNNING)).isTrue(); + } +} diff --git a/cds-starter-ai/pom.xml b/cds-starter-ai/pom.xml new file mode 100644 index 0000000..c9cf33f --- /dev/null +++ b/cds-starter-ai/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + + com.sap.cds + cds-ai-root + ${revision} + + + cds-starter-ai + + jar + + CDS Starter AI + Starter package for AI features in CAP Java applications. Aggregates the cds-feature-ai-core and cds-feature-recommendations modules so applications can pull both with a single dependency. + + + + com.sap.cds + cds-feature-ai-core + + + + com.sap.cds + cds-feature-recommendations + runtime + + + + + + diff --git a/coverage-report/pom.xml b/coverage-report/pom.xml new file mode 100644 index 0000000..6f31290 --- /dev/null +++ b/coverage-report/pom.xml @@ -0,0 +1,152 @@ + + + 4.0.0 + + + com.sap.cds + cds-ai-root + ${revision} + + + cds-feature-ai-coverage-report + pom + + CDS Feature AI - Coverage Report + Aggregated JaCoCo coverage report combining unit tests and integration tests. + + + + + + com.sap.cds + cds-feature-ai-core + + + com.sap.cds + cds-feature-recommendations + + + com.sap.cds + cds-feature-sap-document-ai + + + + + com.sap.cds + cds-feature-ai-integration-tests-spring + test + + + + + + + + maven-deploy-plugin + + true + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + true + + + + maven-pmd-plugin + + true + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-classes + + copy-resources + + prepare-package + + ${project.build.directory}/classes + + + ${project.basedir}/../cds-feature-ai-core/target/classes + + + ${project.basedir}/../cds-feature-recommendations/target/classes + + + ${project.basedir}/../cds-feature-sap-document-ai/target/classes + + + + + + + + + org.jacoco + jacoco-maven-plugin + + + + jacoco-merge + + merge + + prepare-package + + + + ${project.basedir}/.. + + cds-feature-ai-core/target/jacoco.exec + cds-feature-recommendations/target/jacoco.exec + cds-feature-sap-document-ai/target/jacoco.exec + integration-tests/spring/target/jacoco.exec + integration-tests/mtx-local/srv/target/jacoco.exec + + + + ${project.build.directory}/jacoco-merged.exec + + + + + + jacoco-aggregate-report + + report-aggregate + + verify + + + + + + + + + mtx-integration-tests + + + com.sap.cds + cds-feature-ai-integration-tests-mtx-local + test + + + + + + diff --git a/gh_ruleset.json b/gh_ruleset.json new file mode 100644 index 0000000..6f88065 --- /dev/null +++ b/gh_ruleset.json @@ -0,0 +1,59 @@ +{ + "name": "main", + "target": "branch", + "source_type": "Repository", + "source": "Repository", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": ["~DEFAULT_BRANCH"] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "build / build-and-run", + "integration_id": 15368 + }, + { + "context": "lint", + "integration_id": 15368 + }, + { + "context": "test (20.x)", + "integration_id": 15368 + }, + { + "context": "test (22.x)", + "integration_id": 15368 + } + ] + } + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "automatic_copilot_code_review_enabled": false, + "allowed_merge_methods": ["squash"] + } + } + ], + "bypass_actors": [] +} diff --git a/integration-tests/.cdsrc.json b/integration-tests/.cdsrc.json new file mode 100644 index 0000000..a939726 --- /dev/null +++ b/integration-tests/.cdsrc.json @@ -0,0 +1,21 @@ +{ + "build": { + "target": ".", + "tasks": [ + { "for": "java", "src": "spring" } + ] + }, + "requires": { + "AICore": { + "model": false + }, + "kinds": { + "AICore-btp": { + "model": false + }, + "AICore-mocked": { + "model": false + } + } + } +} diff --git a/integration-tests/README.md b/integration-tests/README.md new file mode 100644 index 0000000..42a660e --- /dev/null +++ b/integration-tests/README.md @@ -0,0 +1,78 @@ +# Integration Tests + +This directory contains integration tests that verify the CAP Java AI plugins against a running Spring Boot application context. + +## Test Modules + +| Module | Description | Default | +|--------|-------------|---------| +| `spring/` | Core integration tests (AI Core client, recommendations, actions, OData) using H2 | Enabled | +| `mtx-local/` | Multi-tenancy integration tests with a local sidecar and SQLite | Disabled | + +## Running Tests + +**Default (spring only):** + +```bash +mvn verify +``` + +**Including MTX tests:** + +```bash +mvn verify -Pmtx-integration-tests +``` + +**Skipping all integration tests (source modules only):** + +```bash +mvn verify -Pskip-integration-tests +``` + +## Profiles + +| Profile | Scope | Effect | +|---------|-------|--------| +| _(default)_ | Root | Builds all modules; runs spring integration tests | +| `mtx-integration-tests` | `integration-tests/` | Also includes the `mtx-local/srv` module | +| `skip-integration-tests` | Root | Excludes `integration-tests/` and `coverage-report/` entirely | + +## Coverage + +Aggregated code coverage is produced by the `coverage-report/` module at the project root. + +### How it works + +1. Each module that runs tests has the JaCoCo agent attached (`prepare-agent`), which writes a `target/jacoco.exec` file during test execution. +2. The `coverage-report` module (built last in the reactor) merges all `.exec` files into a single `target/jacoco-merged.exec`. +3. It then generates an aggregated HTML/XML report via `jacoco:report-aggregate` and runs `jacoco:check` against configurable thresholds. + +### Generating the report + +```bash +mvn clean verify +``` + +The aggregated report is at: + +``` +coverage-report/target/site/jacoco-aggregate/index.html +``` + +### Thresholds + +Per-module thresholds are defined in `coverage-report/pom.xml`: + +| Module | Instruction | Branch | Complexity | +|--------|-------------|--------|------------| +| `cds-feature-ai-core` | 0% | 0% | 0% | +| `cds-feature-recommendations` | 80% | 80% | 80% | + +### Coverage data sources + +The merged report combines execution data from: + +- `cds-feature-ai-core/target/jacoco.exec` (unit tests) +- `cds-feature-recommendations/target/jacoco.exec` (unit tests) +- `integration-tests/spring/target/jacoco.exec` (integration tests) +- `integration-tests/mtx-local/srv/target/jacoco.exec` (MTX integration tests, only when profile is active) diff --git a/integration-tests/db/schema.cds b/integration-tests/db/schema.cds new file mode 100644 index 0000000..281f983 --- /dev/null +++ b/integration-tests/db/schema.cds @@ -0,0 +1,45 @@ +namespace itest; + +using { managed, cuid } from '@sap/cds/common'; + +entity Products { + key ID : Integer; + name : String; + price : Decimal; + category : String; +} + +@cds.odata.valuelist +entity Categories { + key ID : Integer; + name : String; +} + +@cds.odata.valuelist +entity Priorities { + key code : String(10); + name : String; +} + +entity Tasks : managed, cuid { + title : String(200); + description : String(1000); + effort : Integer; + category : Association to Categories; + priority : Association to Priorities; +} + +entity BooksWithCustomKey : managed { + key isbn : String(20); + title : String(200); + price : Decimal; + category : Association to Categories; +} + +entity OrderItems : managed { + key order_no : Integer; + key item_no : Integer; + product : String(200); + quantity : Integer; + category : Association to Categories; +} diff --git a/integration-tests/mtx-local/.cdsrc.json b/integration-tests/mtx-local/.cdsrc.json new file mode 100644 index 0000000..bd8df80 --- /dev/null +++ b/integration-tests/mtx-local/.cdsrc.json @@ -0,0 +1,14 @@ +{ + "profile": "with-mtx-sidecar", + "requires": { + "multitenancy": true, + "extensibility": true, + "toggles": true, + "db": { + "kind": "sqlite" + } + }, + "cdsc": { + "moduleLookupDirectories": ["node_modules/", "target/cds/"] + } +} diff --git a/integration-tests/mtx-local/.gitignore b/integration-tests/mtx-local/.gitignore new file mode 100644 index 0000000..26ca74f --- /dev/null +++ b/integration-tests/mtx-local/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.sqlite +*.sqlite-* +*.db +package-lock.json diff --git a/integration-tests/mtx-local/db/schema.cds b/integration-tests/mtx-local/db/schema.cds new file mode 100644 index 0000000..4a1fa32 --- /dev/null +++ b/integration-tests/mtx-local/db/schema.cds @@ -0,0 +1,8 @@ +namespace itest.mt; + +entity Products { + key ID : Integer; + name : String; + price : Decimal; + category : String; +} diff --git a/integration-tests/mtx-local/mtx/sidecar/package.json b/integration-tests/mtx-local/mtx/sidecar/package.json new file mode 100644 index 0000000..b2936eb --- /dev/null +++ b/integration-tests/mtx-local/mtx/sidecar/package.json @@ -0,0 +1,30 @@ +{ + "name": "mtx-local-sidecar", + "version": "0.0.0", + "dependencies": { + "@sap/cds": "^9", + "@sap/cds-mtxs": "^3", + "@sap/xssec": "^4", + "express": "^4" + }, + "devDependencies": { + "@cap-js/sqlite": "^2" + }, + "cds": { + "profile": "mtx-sidecar", + "[development]": { + "requires": { + "auth": "dummy" + }, + "db": { + "kind": "sqlite", + "credentials": { + "url": "../../db.sqlite" + } + } + } + }, + "scripts": { + "start": "cds-serve --profile development" + } +} diff --git a/integration-tests/mtx-local/package.json b/integration-tests/mtx-local/package.json new file mode 100644 index 0000000..7d5d968 --- /dev/null +++ b/integration-tests/mtx-local/package.json @@ -0,0 +1,12 @@ +{ + "name": "mtx-local-integration-tests", + "version": "0.0.0", + "devDependencies": { + "@sap/cds": "^9", + "@sap/cds-dk": "^9", + "@sap/cds-mtxs": "^3" + }, + "workspaces": [ + "mtx/sidecar" + ] +} diff --git a/integration-tests/mtx-local/srv/pom.xml b/integration-tests/mtx-local/srv/pom.xml new file mode 100644 index 0000000..a4af600 --- /dev/null +++ b/integration-tests/mtx-local/srv/pom.xml @@ -0,0 +1,255 @@ + + + 4.0.0 + + + com.sap.cds + cds-feature-ai-integration-tests + ${revision} + ../../pom.xml + + + cds-feature-ai-integration-tests-mtx-local + jar + CDS Feature AI - Integration Tests - MTX Local + + + true + true + true + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + + com.sap.cds + cds-starter-spring-boot-odata + + + + + com.sap.cds + cds-feature-ai-core + + + + + com.sap.cds + cds-starter-cloudfoundry + + + + + org.xerial + sqlite-jdbc + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + test + + + + + + + + com.sap.cds + cds-maven-plugin + + + cds.clean + + clean + + + + + cds.install-node + + install-node + + + + + cds.resolve + + resolve + + + ${project.basedir}/.. + ${project.basedir}/.. + + + + + install-dependencies + + npm + + + ${project.basedir}/.. + + install + + + + + + cds.build + + cds + + + ${project.basedir}/.. + + build --for java + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + com.sap.cds.feature.aicore.itest.mt.Application + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + execute-local-integration-tests + + integration-test + + integration-test + + + **/**/*Test.java + + + + + verify-local-integration-tests + + verify + + + + + + + org.jacoco + jacoco-maven-plugin + + + jacoco-initialize + + prepare-agent + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-cds-models-to-sidecar + + copy-resources + + pre-integration-test + + ${project.basedir}/../node_modules/com.sap.cds/ai + + + ${project.basedir}/../target/cds/com.sap.cds/ai + + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + + ${cds.node.directory}${path.separator}${env.PATH} + + ${skipTests} + + + + start-sidecar + + exec + + pre-integration-test + + ${cds.npm.executable} + ${project.basedir}/../mtx/sidecar + true + true + run start + + + + stop-sidecar + + exec + + post-integration-test + + sh + -c "lsof -ti :4005 | xargs kill 2>/dev/null || true" + + + + + + + + diff --git a/integration-tests/mtx-local/srv/service.cds b/integration-tests/mtx-local/srv/service.cds new file mode 100644 index 0000000..e7036f6 --- /dev/null +++ b/integration-tests/mtx-local/srv/service.cds @@ -0,0 +1,6 @@ +using {itest.mt} from '../db/schema'; +using from 'com.sap.cds/ai'; + +service MtTestService { + entity Products as projection on mt.Products; +} diff --git a/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/aicore/itest/mt/Application.java b/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/aicore/itest/mt/Application.java new file mode 100644 index 0000000..1897d22 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/aicore/itest/mt/Application.java @@ -0,0 +1,15 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest.mt; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/integration-tests/mtx-local/srv/src/main/resources/application.yaml b/integration-tests/mtx-local/srv/src/main/resources/application.yaml new file mode 100644 index 0000000..2bf2b72 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/main/resources/application.yaml @@ -0,0 +1,23 @@ +cds: + multi-tenancy: + sidecar: + url: http://localhost:4005 + security: + mock: + users: + - name: user-in-tenant-1 + tenant: tenant-1 + - name: user-in-tenant-2 + tenant: tenant-2 + - name: user-in-tenant-3 + tenant: tenant-3 + +--- +spring: + config.activate.on-profile: local-with-tenants +cds: + security: + mock: + tenants: + - name: tenant-1 + - name: tenant-2 diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java new file mode 100644 index 0000000..30e5a4d --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java @@ -0,0 +1,80 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest.mt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.runtime.CdsRuntime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +/** Verifies the {@code AICoreSetupHandler} lifecycle is idempotent across repeated invocations. */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("local-with-tenants") +class MtxLifecycleTest { + + private static final String TENANT = "tenant-3"; + + @Autowired MockMvc client; + @Autowired ObjectMapper objectMapper; + @Autowired CdsRuntime runtime; + + SubscriptionEndpointClient subscriptionEndpointClient; + + @BeforeEach + void setup() { + subscriptionEndpointClient = new SubscriptionEndpointClient(objectMapper, client); + } + + @AfterEach + void tearDown() { + try { + subscriptionEndpointClient.unsubscribeTenant(TENANT); + } catch (Exception ignored) { + } + } + + @Test + void unsubscribe_isIdempotent() throws Exception { + subscriptionEndpointClient.subscribeTenant(TENANT); + subscriptionEndpointClient.unsubscribeTenant(TENANT); + + assertThatCode(() -> subscriptionEndpointClient.unsubscribeTenant(TENANT)) + .doesNotThrowAnyException(); + } + + @Test + void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { + RemoteService service = getService(); + + for (int i = 0; i < 2; i++) { + subscriptionEndpointClient.subscribeTenant(TENANT); + // After subscribe, the service should resolve a resource group for this tenant + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(TENANT); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + assertThat(rg).isNotNull().isNotBlank(); + + subscriptionEndpointClient.unsubscribeTenant(TENANT); + } + } + + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java new file mode 100644 index 0000000..185d387 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java @@ -0,0 +1,93 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest.mt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.runtime.CdsRuntime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("local-with-tenants") +class SubscribeUnsubscribeTest { + + private static final String PRODUCTS_URL = "/odata/v4/MtTestService/Products"; + + @Autowired MockMvc client; + @Autowired ObjectMapper objectMapper; + @Autowired CdsRuntime runtime; + + SubscriptionEndpointClient subscriptionEndpointClient; + + @BeforeEach + void setup() { + subscriptionEndpointClient = new SubscriptionEndpointClient(objectMapper, client); + } + + @Test + void subscribeTenant_thenServiceIsReachable() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + client + .perform(get(PRODUCTS_URL).with(httpBasic("user-in-tenant-3", ""))) + .andExpect(status().isOk()); + } + + @Test + void subscribeTenant_createsResourceGroup() throws Exception { + RemoteService service = getService(); + + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + // After subscription, the service should be able to resolve a resource group for the tenant + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-3"); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); + assertThat(resourceGroup).isNotNull().isNotBlank(); + } + + @Test + void unsubscribeTenant_thenServiceFails() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + client + .perform(get(PRODUCTS_URL).with(httpBasic("user-in-tenant-3", ""))) + .andExpect(status().isOk()); + + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + + client + .perform(get(PRODUCTS_URL).with(httpBasic("user-in-tenant-3", ""))) + .andExpect(status().isInternalServerError()); + } + + @AfterEach + void tearDown() { + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + } catch (Exception ignored) { + } + } + + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java new file mode 100644 index 0000000..d30830f --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java @@ -0,0 +1,109 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest.mt; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.runtime.CdsRuntime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("local-with-tenants") +class TenantIsolationTest { + + @Autowired MockMvc client; + @Autowired ObjectMapper objectMapper; + @Autowired CdsRuntime runtime; + + SubscriptionEndpointClient subscriptionEndpointClient; + + @BeforeEach + void setup() { + subscriptionEndpointClient = new SubscriptionEndpointClient(objectMapper, client); + } + + @Test + void multiTenancyEnabled() { + AICoreConfig config = getConfig(); + assertThat(config.multiTenancyEnabled()).isTrue(); + } + + @Test + void differentTenants_getDifferentResourceGroups() throws Exception { + RemoteService service = getService(); + + subscriptionEndpointClient.subscribeTenant("tenant-1"); + subscriptionEndpointClient.subscribeTenant("tenant-2"); + + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("tenant-1"); + service.emit(rgCtx1); + String rg1 = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("tenant-2"); + service.emit(rgCtx2); + String rg2 = rgCtx2.getResult(); + + assertThat(rg1).isNotNull(); + assertThat(rg2).isNotNull(); + assertThat(rg1).isNotEqualTo(rg2); + } + + @Test + void resourceGroupPrefix_applied() throws Exception { + AICoreConfig config = getConfig(); + RemoteService service = getService(); + + subscriptionEndpointClient.subscribeTenant("tenant-1"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-1"); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + + assertThat(rg).startsWith(config.resourceGroupPrefix()); + } + + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + private AICoreConfig getConfig() { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + boolean mt = sidecarUrl != null && !sidecarUrl.isBlank(); + return AICoreConfig.from(runtime.getEnvironment(), mt); + } + + @AfterEach + void tearDown() { + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-1"); + } catch (Throwable ignored) { + } + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-2"); + } catch (Throwable ignored) { + } + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + } catch (Throwable ignored) { + } + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/utils/SubscriptionEndpointClient.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/utils/SubscriptionEndpointClient.java new file mode 100644 index 0000000..d87d599 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/utils/SubscriptionEndpointClient.java @@ -0,0 +1,68 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest.mt.utils; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +public class SubscriptionEndpointClient { + + private static final String MT_SUBSCRIPTIONS_TENANTS = "/mt/v1.0/subscriptions/tenants/"; + + private final ObjectMapper objectMapper; + private final MockMvc client; + private final String credentials = + "Basic " + Base64.getEncoder().encodeToString("privileged:".getBytes(StandardCharsets.UTF_8)); + + public SubscriptionEndpointClient(ObjectMapper objectMapper, MockMvc client) { + this.objectMapper = objectMapper; + this.client = client; + } + + public void subscribeTenant(String tenant) throws Exception { + SubscriptionPayload payload = new SubscriptionPayload(); + payload.subscribedTenantId = tenant; + payload.subscribedSubdomain = tenant.concat(".sap.com"); + payload.eventType = "CREATE"; + + client + .perform( + put(MT_SUBSCRIPTIONS_TENANTS.concat(payload.subscribedTenantId)) + .header(HttpHeaders.AUTHORIZATION, credentials) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isCreated()); + } + + public void unsubscribeTenant(String tenant) throws Exception { + DeletePayload payload = new DeletePayload(); + payload.subscribedTenantId = tenant; + + client + .perform( + delete(MT_SUBSCRIPTIONS_TENANTS.concat(payload.subscribedTenantId)) + .header(HttpHeaders.AUTHORIZATION, credentials) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isNoContent()); + } + + static class SubscriptionPayload { + public String subscribedTenantId; + public String subscribedSubdomain; + public String eventType; + } + + static class DeletePayload { + public String subscribedTenantId; + } +} diff --git a/integration-tests/package-lock.json b/integration-tests/package-lock.json new file mode 100644 index 0000000..bd715ec --- /dev/null +++ b/integration-tests/package-lock.json @@ -0,0 +1,2795 @@ +{ + "name": "cds-feature-ai-integration-tests", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cds-feature-ai-integration-tests", + "version": "0.0.0", + "dependencies": { + "@cap-js/ai": "^1", + "@sap/cds": "^9" + }, + "devDependencies": { + "@sap/cds-dk": "^9" + } + }, + "node_modules/@cap-js/ai": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@cap-js/ai/-/ai-1.0.1.tgz", + "integrity": "sha512-QE5JZTvbptGpcpSy+KgSn6IwQuB7ugmNWQ0dpX7OwXjB5MIn2utKKjrkWy0Gs1O0mEJEARl6w519UtZiY30ufQ==", + "license": "Apache-2.0", + "workspaces": [ + "tests/*" + ], + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=9" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@sap/cds": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@sap/cds/-/cds-9.9.1.tgz", + "integrity": "sha512-GqdsBsRkZThhpOyzj8ihf/jDmf/2zprZFgaun6ZymUw4/ahzjK/bbdd6eQ8txDuv88pnUl2HPFjvUVq3O/6hCA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/cds-compiler": "^6.4", + "@sap/cds-fiori": "^2", + "express": "^4.22.1 || ^5", + "yaml": "^2" + }, + "bin": { + "cds-deploy": "bin/deploy.js", + "cds-serve": "bin/serve.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@eslint/js": "^9 || ^10", + "tar": "^7.5.6" + }, + "peerDependenciesMeta": { + "tar": { + "optional": true + } + } + }, + "node_modules/@sap/cds-compiler": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/@sap/cds-compiler/-/cds-compiler-6.9.2.tgz", + "integrity": "sha512-Qv7Zb3RhG92WVm1AjHEJaYbOi3tNT051/EWPYTsYdUe5epYXbR4dJfGpD1eEgo82ThrKCFx0BZfT0b28t0/vqg==", + "license": "SEE LICENSE IN LICENSE", + "bin": { + "cdsc": "bin/cdsc.js", + "cdshi": "bin/cdshi.js", + "cdsse": "bin/cdsse.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sap/cds-dk": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@sap/cds-dk/-/cds-dk-9.9.1.tgz", + "integrity": "sha512-cZoHI/ZhEVffmLo2k9Y/HMR5X+aGCpk60PwJJcZgoat8Kwk6dDl3mUDERhZORQUhp9FwOiyWmNujmNCV8YWWCg==", + "dev": true, + "hasShrinkwrap": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@cap-js/asyncapi": "^1.0.0", + "@cap-js/openapi": "^1.0.0", + "@sap/cds": "^8.3 || ^9", + "@sap/cds-mtxs": "^2 || ^3", + "@sap/hdi-deploy": "^5", + "express": "^4.22.1 || ^5", + "hdb": "^2.0.0", + "livereload-js": "^4.0.1", + "mustache": "^4.0.1", + "ws": "^8.4.2", + "xml-js": "^1.6.11", + "yaml": "^2" + }, + "bin": { + "cds": "bin/cds.js", + "cds-ts": "bin/cds-ts.js", + "cds-tsx": "bin/cds-tsx.js" + }, + "optionalDependencies": { + "@cap-js/sqlite": ">=1" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/asyncapi": { + "version": "1.0.3", + "integrity": "sha512-vZSWKAe+3qfvZDXV5SSFiObGWmqyS9MDyEADb5PLVT8kzO39qGaSDPv/GzI/gwvRfCayGAjU4ThiBKrFA7Gclg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/db-service": { + "version": "2.11.0", + "integrity": "sha512-sl33LcxZYAJgMCQZDw4lMGe4kWYq6685Xc6ze4qcoM+rd6aqiyVsSC6C7XH5yerXs7cVHhRC+Dgo8AsaapFzlQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "generic-pool": "^3.9.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/openapi": { + "version": "1.4.0", + "integrity": "sha512-/LRSwn4SDxAi3qKwl09zoOhEVGaPGlYOPz/0S3UBnaMJVvaLyPiKbbaOtOnrrgulUX5OXt+ujPIQznOsbTzuAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "pluralize": "^8.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/sqlite": { + "version": "2.4.0", + "integrity": "sha512-Ao+AzIN6BWHNpLbGxAzF79OezFNHzDG2srwiBABs0FYxIxEGkc2hg6ETo79pTTt66gcWtx7pWh/N9xk2M6SFBQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@cap-js/db-service": "^2.11.0", + "better-sqlite3": "^12.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8", + "sql.js": "^1.13.0" + }, + "peerDependenciesMeta": { + "sql.js": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@eslint/js": { + "version": "10.0.1", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds": { + "version": "9.9.1", + "integrity": "sha512-GqdsBsRkZThhpOyzj8ihf/jDmf/2zprZFgaun6ZymUw4/ahzjK/bbdd6eQ8txDuv88pnUl2HPFjvUVq3O/6hCA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/cds-compiler": "^6.4", + "@sap/cds-fiori": "^2", + "express": "^4.22.1 || ^5", + "yaml": "^2" + }, + "bin": { + "cds-deploy": "bin/deploy.js", + "cds-serve": "bin/serve.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@eslint/js": "^9 || ^10", + "tar": "^7.5.6" + }, + "peerDependenciesMeta": { + "tar": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-compiler": { + "version": "6.9.1", + "integrity": "sha512-j5C61t1mPhMW3vpD3LIRVn40DMiIF2XahOPeJIPjRpUiGMbQPdVreqAhiRHg39GYhSK6etlr5/MIx3a2ljtqHg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "bin": { + "cdsc": "bin/cdsc.js", + "cdshi": "bin/cdshi.js", + "cdsse": "bin/cdsse.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-fiori": { + "version": "2.3.0", + "integrity": "sha512-6oWov+DSpFrSTgxXR0dZhak6aZ/IVRZvaHERMi0EgSTzIJdlvZlpw3Kf18ePMcTrRrtEXwD4RIjKt8pbs0g2Hg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-mtxs": { + "version": "3.9.0", + "integrity": "sha512-U9H9NXQxlxSNwSD/6U59+Egn9LIE2SRdu8i5bZqEG2GB4xEU6csduy0kY4EWvi8XXD8onbFSgw4AA9SB4pN0Yg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/hdi-deploy": "^5" + }, + "bin": { + "cds-mtx": "bin/cds-mtx.js", + "cds-mtx-migrate": "bin/cds-mtx-migrate.js" + }, + "peerDependencies": { + "@sap/cds": ">=9" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi": { + "version": "4.8.0", + "integrity": "sha512-tkJmY2ffm6mt4/LFwRBihlQkMxNAXa3ngvRe2N/6+qLIsUNdrH/M03S5mkygXq56K+KoVVZYuradajCusMWwsw==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "async": "^3.2.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.5", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi-deploy": { + "version": "5.6.1", + "integrity": "sha512-+qQ7qwG8lko303L5yRj2dud/nDAVuVblV/mmzJT44oPbF0Nry18eD2LUS23hFeuxjRa7rYK5YKQ8ffGgWxVrYQ==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "@sap/hdi": "^4.8.0", + "@sap/xsenv": "^6.0.0", + "async": "^3.2.6", + "dotenv": "^16.4.5", + "handlebars": "^4.7.8", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=18.x" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.6", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/xsenv": { + "version": "6.2.0", + "integrity": "sha512-8jrsX1OAM3YUqGU+4deggqvkxrBrHAPYEllBX0YJfWNffgxSZKHG75bRd/RV6hxPwulPL0DeHfd2eYJMeY5gdw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE file", + "dependencies": { + "debug": "4.4.3", + "node-cache": "^5.1.2", + "verror": "1.10.1" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/accepts": { + "version": "2.0.0", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/assert-plus": { + "version": "1.0.0", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/async": { + "version": "3.2.6", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/base64-js": { + "version": "1.5.1", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/better-sqlite3": { + "version": "12.9.0", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/@sap/cds-dk/node_modules/bindings": { + "version": "1.5.0", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/bl": { + "version": "4.1.0", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/body-parser": { + "version": "2.2.2", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/braces": { + "version": "3.0.3", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/buffer": { + "version": "5.7.1", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/@sap/cds-dk/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/chownr": { + "version": "1.1.4", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/clone": { + "version": "2.1.2", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-disposition": { + "version": "1.1.0", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie-signature": { + "version": "1.2.2", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/core-util-is": { + "version": "1.0.2", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/decompress-response": { + "version": "6.0.0", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/deep-extend": { + "version": "0.6.0", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/detect-libc": { + "version": "2.1.2", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/dotenv": { + "version": "16.6.1", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sap/cds-dk/node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/end-of-stream": { + "version": "1.4.5", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-object-atoms": { + "version": "1.1.1", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/etag": { + "version": "1.8.1", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/expand-template": { + "version": "2.0.3", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/express": { + "version": "5.2.1", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/extsprintf": { + "version": "1.4.1", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/file-uri-to-path": { + "version": "1.0.0", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/fill-range": { + "version": "7.1.1", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/finalhandler": { + "version": "2.1.1", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/fresh": { + "version": "2.0.0", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/fs-constants": { + "version": "1.0.0", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/generic-pool": { + "version": "3.9.0", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/github-from-package": { + "version": "0.0.0", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/handlebars": { + "version": "4.7.9", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/hasown": { + "version": "2.0.3", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb": { + "version": "2.27.1", + "integrity": "sha512-xYL/W+fq2TyGHyzm8muolQnw8tdh4+2NQ8mQP2FpLSuhfJ8l0jQNSUZoAXic7NfMEan1Jvf8V1L4blwkgTc6+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "iconv-lite": "0.7.0" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "lz4-wasm-nodejs": "0.9.2" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb/node_modules/iconv-lite": { + "version": "0.7.0", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/iconv-lite": { + "version": "0.7.2", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/ieee754": { + "version": "1.2.1", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ini": { + "version": "1.3.8", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-number": { + "version": "7.0.0", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-promise": { + "version": "4.0.0", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/livereload-js": { + "version": "4.0.2", + "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/lz4-wasm-nodejs": { + "version": "0.9.2", + "integrity": "sha512-hSwgJPS98q/Oe/89Y1OxzeA/UdnASG8GvldRyKa7aZyoAFCC8VPRtViBSava7wWC66WocjUwBpWau2rEmyFPsw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/math-intrinsics": { + "version": "1.1.0", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/media-typer": { + "version": "1.1.0", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/merge-descriptors": { + "version": "2.0.0", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch": { + "version": "4.0.8", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-types": { + "version": "3.0.2", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/mimic-response": { + "version": "3.1.0", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/minimist": { + "version": "1.2.8", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/mkdirp-classic": { + "version": "0.5.3", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/mustache": { + "version": "4.2.0", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/@sap/cds-dk/node_modules/napi-build-utils": { + "version": "2.0.0", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/negotiator": { + "version": "1.0.0", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/neo-async": { + "version": "2.6.2", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/node-abi": { + "version": "3.92.0", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/node-cache": { + "version": "5.1.2", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/object-inspect": { + "version": "1.13.4", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/on-finished": { + "version": "2.4.1", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/once": { + "version": "1.4.0", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/@sap/cds-dk/node_modules/parseurl": { + "version": "1.3.3", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/path-to-regexp": { + "version": "8.4.2", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/pluralize": { + "version": "8.0.0", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sap/cds-dk/node_modules/prebuild-install": { + "version": "7.1.3", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/proxy-addr": { + "version": "2.0.7", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/pump": { + "version": "3.0.4", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/@sap/cds-dk/node_modules/qs": { + "version": "6.15.1", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/raw-body": { + "version": "3.0.2", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/rc": { + "version": "1.2.8", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/readable-stream": { + "version": "3.6.2", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sap/cds-dk/node_modules/router": { + "version": "2.2.0", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sap/cds-dk/node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/sax": { + "version": "1.6.0", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/semver": { + "version": "7.7.4", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/send": { + "version": "1.2.1", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/serve-static": { + "version": "2.2.1", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/side-channel": { + "version": "1.1.0", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-weakmap": { + "version": "1.0.2", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/simple-concat": { + "version": "1.0.1", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/simple-get": { + "version": "4.0.1", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/string_decoder": { + "version": "1.3.0", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/strip-json-comments": { + "version": "2.0.1", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-fs": { + "version": "2.1.4", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-stream": { + "version": "2.2.0", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/to-regex-range": { + "version": "5.0.1", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/tunnel-agent": { + "version": "0.6.0", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@sap/cds-dk/node_modules/type-is": { + "version": "2.0.1", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/uglify-js": { + "version": "3.19.3", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/util-deprecate": { + "version": "1.0.2", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/vary": { + "version": "1.1.2", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/verror": { + "version": "1.10.1", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/wordwrap": { + "version": "1.0.0", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/wrappy": { + "version": "1.0.2", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ws": { + "version": "8.20.0", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/xml-js": { + "version": "1.6.11", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/yaml": { + "version": "2.8.4", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@sap/cds-fiori": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sap/cds-fiori/-/cds-fiori-2.3.0.tgz", + "integrity": "sha512-6oWov+DSpFrSTgxXR0dZhak6aZ/IVRZvaHERMi0EgSTzIJdlvZlpw3Kf18ePMcTrRrtEXwD4RIjKt8pbs0g2Hg==", + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=8" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/integration-tests/package.json b/integration-tests/package.json new file mode 100644 index 0000000..cf7a272 --- /dev/null +++ b/integration-tests/package.json @@ -0,0 +1,12 @@ +{ + "name": "cds-feature-ai-integration-tests", + "version": "0.0.0", + "private": true, + "dependencies": { + "@cap-js/ai": "^1", + "@sap/cds": "^9" + }, + "devDependencies": { + "@sap/cds-dk": "^9" + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml new file mode 100644 index 0000000..43fc8c4 --- /dev/null +++ b/integration-tests/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + com.sap.cds + cds-ai-root + ${revision} + + + cds-feature-ai-integration-tests + pom + CDS Feature AI - Integration Tests + + + spring + + + + + mtx-integration-tests + + spring + mtx-local/srv + + + + diff --git a/integration-tests/spring/pom.xml b/integration-tests/spring/pom.xml new file mode 100644 index 0000000..9d3a1b7 --- /dev/null +++ b/integration-tests/spring/pom.xml @@ -0,0 +1,163 @@ + + + 4.0.0 + + + com.sap.cds + cds-feature-ai-integration-tests + ${revision} + + + cds-feature-ai-integration-tests-spring + jar + CDS Feature AI - Integration Tests - Spring + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + + com.sap.cds + cds-starter-spring-boot-odata + + + + + com.sap.cds + cds-feature-ai-core + + + + com.sap.cds + cds-feature-recommendations + + + + com.sap.cds + cds-feature-sap-document-ai + + + + + com.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + test + + + + org.awaitility + awaitility + 4.2.2 + test + + + + + + + com.sap.cds + cds-maven-plugin + + + cds.install-node + + install-node + + + + cds.npm-ci + + npm + + + ${project.basedir}/.. + ci + + + + cds.resolve + + resolve + + + ${project.basedir}/.. + + + + cds.build + + cds + + + ${project.basedir}/.. + + build --for java + deploy spring/test-service.cds --to h2 --dry > + "${project.basedir}/src/main/resources/schema.sql" + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + com.sap.cds.feature.aicore.itest.Application + + + + + maven-surefire-plugin + + alphabetical + + + + + org.jacoco + jacoco-maven-plugin + + + jacoco-initialize + + prepare-agent + + + + jacoco-site-report + + report + + verify + + + + + + diff --git a/integration-tests/spring/src/main/java/com/sap/cds/feature/Application.java b/integration-tests/spring/src/main/java/com/sap/cds/feature/Application.java new file mode 100644 index 0000000..b839afa --- /dev/null +++ b/integration-tests/spring/src/main/java/com/sap/cds/feature/Application.java @@ -0,0 +1,15 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/integration-tests/spring/src/main/resources/application.yaml b/integration-tests/spring/src/main/resources/application.yaml new file mode 100644 index 0000000..b256334 --- /dev/null +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -0,0 +1,30 @@ +spring: + datasource: + url: "jdbc:h2:mem:testdb" + driver-class-name: org.h2.Driver + sql: + init: + mode: always + +cds: + model: + include-ui-annotations: true + ai: + core: + resourceGroup: ${CDS_AICORE_TEST_RESOURCE_GROUP:cap-java-ai-default} + maxRetries: 15 + security: + mock: + users: + test-user: + password: pass + tenant-a-user: + password: pass + tenant: tenant-a + tenant-b-user: + password: pass + tenant: tenant-b + admin-user: + password: pass + roles: + - admin diff --git a/integration-tests/spring/src/main/resources/spotbugs-exclusion-filter.xml b/integration-tests/spring/src/main/resources/spotbugs-exclusion-filter.xml new file mode 100644 index 0000000..64e06e6 --- /dev/null +++ b/integration-tests/spring/src/main/resources/spotbugs-exclusion-filter.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java new file mode 100644 index 0000000..795d6f8 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java @@ -0,0 +1,93 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.recommendation.api.RptModelSpec; +import com.sap.cds.services.cds.RemoteService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AICoreServiceTest extends BaseIntegrationTest { + + @BeforeAll + void prepareDeployment() { + ensureRptDeploymentReady(); + } + + @Test + void service_isRegistered() { + assertThat(getAICoreService()).isNotNull(); + assertThat(getAICoreService()).isInstanceOf(RemoteService.class); + } + + @Test + void resourceGroupForTenant_singleTenancy_returnsDefault() { + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + if (!config.multiTenancyEnabled()) { + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + String result = rgCtx.getResult(); + assertThat(result).isEqualTo(config.defaultResourceGroup()); + } + } + + @Test + void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + if (config.multiTenancyEnabled()) { + String tenantId = "itest-svc-tenant-" + System.currentTimeMillis(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantId); + service.emit(rgCtx); + String resourceGroupId = rgCtx.getResult(); + assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); + assertThat(resourceGroupId).contains(tenantId); + + // Second call should return cached value + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId(tenantId); + service.emit(rgCtx2); + String cached = rgCtx2.getResult(); + assertThat(cached).isEqualTo(resourceGroupId); + } + } + + @Test + void deploymentId_returnsDeploymentId() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + assertThat(deploymentId).isNotNull().isNotBlank(); + + // Second call should use cache + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId(resourceGroup); + depCtx2.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx2); + String cached = depCtx2.getResult(); + assertThat(cached).isEqualTo(deploymentId); + } + + @Test + void configProperties_areApplied() { + AICoreConfig config = getAICoreConfig(); + assertThat(config.defaultResourceGroup()).isNotBlank(); + assertThat(config.resourceGroupPrefix()).isNotBlank(); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java new file mode 100644 index 0000000..42ae5d3 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java @@ -0,0 +1,138 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.sap.cds.Result; +import com.sap.cds.Row; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.feature.recommendation.api.RptModelSpec; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.RemoteService; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@ExtendWith(ResourceGroupCleanupExtension.class) +class ActionTest extends BaseIntegrationTest { + + @BeforeAll + void ensureResourceGroupReady() { + ensureResourceGroupProvisioned(getAICoreService(), getAICoreConfig().defaultResourceGroup()); + } + + @Test + void resourceGroupForTenant_singleTenancy_returnsDefault() { + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant-id"); + service.emit(rgCtx); + String result = rgCtx.getResult(); + assertThat(result).isEqualTo(config.defaultResourceGroup()); + } + + @Test + void resourceGroupForTenant_multiTenancy_createsGroup() { + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantId = "itest-action-tenant-" + System.currentTimeMillis(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantId); + service.emit(rgCtx); + String resourceGroupId = rgCtx.getResult(); + assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); + assertThat(resourceGroupId).contains(tenantId); + } + + @Test + void deploymentId_returnsValidDeployment() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + assertThat(deploymentId).isNotNull().isNotBlank(); + } + + @Test + void deploymentId_cachedOnSecondCall() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + DeploymentIdContext depCtx1 = DeploymentIdContext.create(); + depCtx1.setResourceGroupId(resourceGroup); + depCtx1.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx1); + String first = depCtx1.getResult(); + + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId(resourceGroup); + depCtx2.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx2); + String second = depCtx2.getResult(); + assertThat(second).isEqualTo(first); + } + + @Disabled( + "Stops the shared RPT deployment needed by subsequent Recommendation tests; " + + "re-enable once test creates its own isolated deployment") + @Test + void stop_deployment_changesTargetStatus() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + Result deployments = + service.run( + Select.from(Deployments_.CDS_NAME) + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); + + String deploymentId = null; + for (Row row : deployments) { + if ("RUNNING".equals(row.get("targetStatus"))) { + deploymentId = (String) row.get("id"); + break; + } + } + + assumeFalse(deploymentId == null, "No running deployment available"); + + final String targetId = deploymentId; + + service.run( + Update.entity(Deployments_.CDS_NAME) + .where(d -> d.get("id").eq(targetId)) + .data( + Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); + + Result readResult = + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("id") + .eq(targetId) + .and(d.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + + assertThat(readResult.list()).hasSize(1); + Row row = readResult.single(); + assertThat(row.get("targetStatus")).isIn("STOPPED", "STOPPING"); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java new file mode 100644 index 0000000..ea356aa --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -0,0 +1,121 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import com.sap.cds.Result; +import com.sap.cds.Row; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; +import com.sap.cds.feature.recommendation.api.RptModelSpec; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +public abstract class BaseIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(BaseIntegrationTest.class); + + static final String ITEST_OWNER_LABEL_KEY = "ext.ai.sap.com/CDS_FEATURE_AI_ITEST_OWNER"; + + private static final ConcurrentMap CACHED_DEPLOYMENT_IDS = + new ConcurrentHashMap<>(); + + @Autowired protected MockMvc mockMvc; + + @Autowired protected CdsRuntime runtime; + + protected RemoteService getAICoreService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + protected AICoreConfig getAICoreConfig() { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + boolean mt = sidecarUrl != null && !sidecarUrl.isBlank(); + return AICoreConfig.from(runtime.getEnvironment(), mt); + } + + protected String ensureRptDeploymentReady() { + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + return CACHED_DEPLOYMENT_IDS.computeIfAbsent( + resourceGroup, + rg -> { + ensureResourceGroupProvisioned(getAICoreService(), rg); + RemoteService service = getAICoreService(); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(rg); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + return depCtx.getResult(); + }); + } + + protected void ensureResourceGroupProvisioned(RemoteService service, String resourceGroup) { + if (!resourceGroupExists(service, resourceGroup)) { + logger.info("Creating resource group {} with itest owner label", resourceGroup); + service.run( + Insert.into(ResourceGroups_.CDS_NAME) + .entry( + Map.of( + "resourceGroupId", + resourceGroup, + "labels", + List.of(Map.of("key", ITEST_OWNER_LABEL_KEY, "value", resourceGroup))))); + } + waitForResourceGroupProvisioned(service, resourceGroup); + } + + private boolean resourceGroupExists(RemoteService service, String resourceGroup) { + Result all = service.run(Select.from(ResourceGroups_.CDS_NAME)); + for (Row row : all) { + if (resourceGroup.equals(row.get("resourceGroupId"))) { + return true; + } + } + return false; + } + + private void waitForResourceGroupProvisioned(RemoteService service, String resourceGroup) { + for (int i = 0; i < 30; i++) { + Result all = service.run(Select.from(ResourceGroups_.CDS_NAME)); + for (Row row : all) { + if (resourceGroup.equals(row.get("resourceGroupId"))) { + String status = (String) row.get("status"); + if ("PROVISIONED".equals(status)) { + return; + } + break; + } + } + sleepQuietly(2000L); + } + throw new IllegalStateException( + "Resource group " + resourceGroup + " did not reach PROVISIONED status"); + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting", e); + } + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java new file mode 100644 index 0000000..db4a823 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java @@ -0,0 +1,137 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.Result; +import com.sap.cds.Row; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.RemoteService; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ConfigurationTest extends BaseIntegrationTest { + + @Test + void readAll_returnsConfigurations() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + Result result = + service.run( + Select.from(Configurations_.CDS_NAME) + .where(c -> c.get("resourceGroup_resourceGroupId").eq(resourceGroup))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void readAll_filterByScenario() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + Result result = + service.run( + Select.from(Configurations_.CDS_NAME) + .where( + c -> + c.get("scenarioId") + .eq("foundation-models") + .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void create_andReadById() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + String configName = "itest-config-" + System.currentTimeMillis(); + Result created = + service.run( + Insert.into(Configurations_.CDS_NAME) + .entry( + Map.of( + "name", + configName, + "executableId", + "aicore-sap", + "scenarioId", + "foundation-models", + "resourceGroup_resourceGroupId", + resourceGroup, + "parameterBindings", + List.of( + Map.of("key", "modelName", "value", "sap-rpt-1-small"), + Map.of("key", "modelVersion", "value", "latest"))))); + + assertThat(created.list()).hasSize(1); + String configId = (String) created.single().get("id"); + assertThat(configId).isNotNull(); + + // Read back by ID + Result readResult = + service.run( + Select.from(Configurations_.CDS_NAME) + .where( + c -> + c.get("id") + .eq(configId) + .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + + assertThat(readResult.list()).hasSize(1); + Row row = readResult.single(); + assertThat(row.get("name")).isEqualTo(configName); + assertThat(row.get("executableId")).isEqualTo("aicore-sap"); + assertThat(row.get("scenarioId")).isEqualTo("foundation-models"); + } + + @Test + void create_withParameterBindings_mapsCorrectly() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + String configName = "itest-params-" + System.currentTimeMillis(); + Result created = + service.run( + Insert.into(Configurations_.CDS_NAME) + .entry( + Map.of( + "name", + configName, + "executableId", + "aicore-sap", + "scenarioId", + "foundation-models", + "resourceGroup_resourceGroupId", + resourceGroup, + "parameterBindings", + List.of( + Map.of("key", "param1", "value", "value1"), + Map.of("key", "param2", "value", "value2"))))); + + String configId = (String) created.single().get("id"); + + Result readResult = + service.run( + Select.from(Configurations_.CDS_NAME) + .where( + c -> + c.get("id") + .eq(configId) + .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + + Row row = readResult.single(); + @SuppressWarnings("unchecked") + List> bindings = (List>) row.get("parameterBindings"); + assertThat(bindings).hasSize(2); + assertThat(bindings) + .anyMatch(b -> "param1".equals(b.get("key")) && "value1".equals(b.get("value"))); + assertThat(bindings) + .anyMatch(b -> "param2".equals(b.get("key")) && "value2".equals(b.get("value"))); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java new file mode 100644 index 0000000..e4fbba8 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java @@ -0,0 +1,105 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import com.sap.cds.Result; +import com.sap.cds.Row; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.RemoteService; +import java.util.Map; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class DeploymentTest extends BaseIntegrationTest { + + @Test + void readAll_returnsDeployments() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + Result result = + service.run( + Select.from(Deployments_.CDS_NAME) + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void readSingle_returnsDeploymentDetails() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + Result all = + service.run( + Select.from(Deployments_.CDS_NAME) + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); + + assumeFalse(all.list().isEmpty(), "No deployments available"); + + String id = (String) all.list().get(0).get("id"); + Result single = + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("id") + .eq(id) + .and(d.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + + assertThat(single.list()).hasSize(1); + Row row = single.single(); + assertThat(row.get("id")).isEqualTo(id); + assertThat(row.get("configurationId")).isNotNull(); + assertThat(row.get("status")).isNotNull(); + } + + @Disabled( + "Stops the shared RPT deployment needed by subsequent Recommendation tests; " + + "re-enable once test creates its own isolated deployment") + @Test + void update_targetStatus_stopsRunningDeployment() { + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + Result deployments = + service.run( + Select.from(Deployments_.CDS_NAME) + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); + + String deploymentId = null; + for (Row row : deployments) { + if ("RUNNING".equals(row.get("targetStatus"))) { + deploymentId = (String) row.get("id"); + break; + } + } + + assumeFalse(deploymentId == null, "No running deployment available to test"); + + final String targetId = deploymentId; + + service.run( + Update.entity(Deployments_.CDS_NAME) + .where(d -> d.get("id").eq(targetId)) + .data( + Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); + + Result readResult = + service.run( + Select.from(Deployments_.CDS_NAME) + .where( + d -> + d.get("id") + .eq(targetId) + .and(d.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + + assertThat(readResult.list()).hasSize(1); + Row row = readResult.single(); + assertThat(row.get("targetStatus")).isIn("STOPPED", "STOPPING"); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java new file mode 100644 index 0000000..9d88af9 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java @@ -0,0 +1,73 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.services.cds.RemoteService; +import org.junit.jupiter.api.Test; + +class MultiTenancyTest extends BaseIntegrationTest { + + @Test + void differentTenants_getDifferentResourceGroups() { + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantA = "itest-mt-a-" + System.currentTimeMillis(); + String tenantB = "itest-mt-b-" + System.currentTimeMillis(); + + ResourceGroupContext rgCtxA = ResourceGroupContext.create(); + rgCtxA.setTenantId(tenantA); + service.emit(rgCtxA); + String rgA = rgCtxA.getResult(); + + ResourceGroupContext rgCtxB = ResourceGroupContext.create(); + rgCtxB.setTenantId(tenantB); + service.emit(rgCtxB); + String rgB = rgCtxB.getResult(); + + assertThat(rgA).isNotEqualTo(rgB); + assertThat(rgA).contains(tenantA); + assertThat(rgB).contains(tenantB); + } + + @Test + void resourceGroupPrefix_appliedCorrectly() { + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantA = "itest-prefix-" + System.currentTimeMillis(); + + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantA); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + assertThat(rg).startsWith(config.resourceGroupPrefix()); + } + + @Test + void singleTenancy_alwaysReturnsDefault() { + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); + + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("tenant-x"); + service.emit(rgCtx1); + String rg1 = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("tenant-y"); + service.emit(rgCtx2); + String rg2 = rgCtx2.getResult(); + + assertThat(rg1).isEqualTo(config.defaultResourceGroup()); + assertThat(rg2).isEqualTo(config.defaultResourceGroup()); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ODataTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ODataTest.java new file mode 100644 index 0000000..c6919f5 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ODataTest.java @@ -0,0 +1,42 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.security.test.context.support.WithMockUser; + +class ODataTest extends BaseIntegrationTest { + + @Test + @WithMockUser(username = "test-user") + void getProducts_returnsODataResponse() throws Exception { + mockMvc + .perform(get("/odata/v4/TestService/Products")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value").isArray()); + } + + @Test + void getProducts_unauthenticated_returnsOk() throws Exception { + mockMvc.perform(get("/odata/v4/TestService/Products")).andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = "test-user") + void getServiceMetadata_returnsMetadata() throws Exception { + mockMvc.perform(get("/odata/v4/TestService/$metadata")).andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = "test-user") + void appContext_startsWithAICoreService() { + assertThat(runtime).isNotNull(); + assertThat(getAICoreService()).isNotNull(); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupCleanupExtension.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupCleanupExtension.java new file mode 100644 index 0000000..61651d6 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupCleanupExtension.java @@ -0,0 +1,53 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JUnit 5 extension that cleans up leaked {@code itest-rg-*} resource groups created by {@link + * ResourceGroupTest}. The main test resource group ({@code itest--...}) is cleaned up by a + * dedicated CI job after ALL parallel integration test jobs complete, to avoid one job's cleanup + * affecting another job's deployment sharing the same AI Core model infrastructure. + */ +public class ResourceGroupCleanupExtension implements AfterAllCallback { + + private static final Logger logger = LoggerFactory.getLogger(ResourceGroupCleanupExtension.class); + + private static final String TEST_RG_PREFIX = "itest-rg-"; + + @Override + public void afterAll(ExtensionContext context) { + ExtensionContext.Store store = context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL); + store.getOrComputeIfAbsent( + "resourceGroupCleanupShutdownHook", + k -> (ExtensionContext.Store.CloseableResource) this::deleteResourceGroupsByPrefix); + } + + private void deleteResourceGroupsByPrefix() { + try { + ResourceGroupApi rgApi = new ResourceGroupApi(); + BckndResourceGroupList all = rgApi.getAll(null, null, null, null, null, null, null); + for (BckndResourceGroup rg : all.getResources()) { + String id = rg.getResourceGroupId(); + if (id != null && id.startsWith(TEST_RG_PREFIX)) { + try { + rgApi.delete(id); + logger.info("Cleaned up leaked test resource group: {}", id); + } catch (Exception e) { + logger.warn("Failed to delete test resource group {}: {}", id, e.getMessage()); + } + } + } + } catch (Exception e) { + logger.warn("Prefix-based resource group cleanup failed: {}", e.getMessage()); + } + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java new file mode 100644 index 0000000..fea4ff2 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java @@ -0,0 +1,154 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.sap.cds.Result; +import com.sap.cds.Row; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; +import com.sap.cds.ql.Delete; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.RemoteService; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@ExtendWith(ResourceGroupCleanupExtension.class) +class ResourceGroupTest extends BaseIntegrationTest { + + private static final String TEST_RG_PREFIX = "itest-rg-"; + private String createdResourceGroupId; + + @AfterEach + void cleanup() { + if (createdResourceGroupId != null) { + try { + RemoteService service = getAICoreService(); + waitForResourceGroupProvisioned(service, createdResourceGroupId); + service.run( + Delete.from(ResourceGroups_.CDS_NAME) + .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); + } catch (Exception ignored) { + } + createdResourceGroupId = null; + } + } + + @Test + void create_andRead_resourceGroup() { + createdResourceGroupId = TEST_RG_PREFIX + System.currentTimeMillis(); + RemoteService service = getAICoreService(); + + service.run( + Insert.into(ResourceGroups_.CDS_NAME) + .entry(Map.of("resourceGroupId", createdResourceGroupId))); + + Result result = + service.run( + Select.from(ResourceGroups_.CDS_NAME) + .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); + + assertThat(result.list()).hasSize(1); + Row row = result.single(); + assertThat(row.get("resourceGroupId")).isEqualTo(createdResourceGroupId); + assertThat(row.get("status")).isNotNull(); + } + + @Test + void create_withTenantLabel_andFilterByTenant() { + String tenantId = "itest-tenant-" + System.currentTimeMillis(); + createdResourceGroupId = TEST_RG_PREFIX + tenantId; + RemoteService service = getAICoreService(); + + service.run( + Insert.into(ResourceGroups_.CDS_NAME) + .entry(Map.of("resourceGroupId", createdResourceGroupId, "tenantId", tenantId))); + + Result result = + service.run( + Select.from(ResourceGroups_.CDS_NAME).where(r -> r.get("tenantId").eq(tenantId))); + + assertThat(result.list()).isNotEmpty(); + Row row = result.first().orElseThrow(); + assertThat(row.get("resourceGroupId")).isEqualTo(createdResourceGroupId); + } + + @Test + void readAll_returnsResourceGroups() { + RemoteService service = getAICoreService(); + Result result = service.run(Select.from(ResourceGroups_.CDS_NAME)); + assertThat(result.list()).isNotNull(); + } + + @Test + void create_withLabels() { + createdResourceGroupId = TEST_RG_PREFIX + "labels-" + System.currentTimeMillis(); + RemoteService service = getAICoreService(); + + service.run( + Insert.into(ResourceGroups_.CDS_NAME) + .entry( + Map.of( + "resourceGroupId", + createdResourceGroupId, + "labels", + List.of( + Map.of( + "key", "ext.ai.sap.com/itest-key", + "value", "itest-value"))))); + + Result result = + service.run( + Select.from(ResourceGroups_.CDS_NAME) + .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); + + assertThat(result.list()).hasSize(1); + Row row = result.single(); + @SuppressWarnings("unchecked") + List> labels = (List>) row.get("labels"); + assertThat(labels).isNotEmpty(); + } + + @Test + void delete_resourceGroup() throws InterruptedException { + String rgId = TEST_RG_PREFIX + "del-" + System.currentTimeMillis(); + RemoteService service = getAICoreService(); + + service.run(Insert.into(ResourceGroups_.CDS_NAME).entry(Map.of("resourceGroupId", rgId))); + + waitForResourceGroupProvisioned(service, rgId); + + assertThatCode( + () -> + service.run( + Delete.from(ResourceGroups_.CDS_NAME) + .where(r -> r.get("resourceGroupId").eq(rgId)))) + .doesNotThrowAnyException(); + + createdResourceGroupId = null; // already deleted + } + + private void waitForResourceGroupProvisioned(RemoteService service, String rgId) + throws InterruptedException { + for (int i = 0; i < 30; i++) { + Result result = + service.run( + Select.from(ResourceGroups_.CDS_NAME).where(r -> r.get("resourceGroupId").eq(rgId))); + if (!result.list().isEmpty()) { + String status = (String) result.single().get("status"); + if ("PROVISIONED".equals(status)) { + return; + } + } + Thread.sleep(2000); + } + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java new file mode 100644 index 0000000..36f2b7f --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java @@ -0,0 +1,69 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.handlers.ExtractionPollingHandler; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cds.ql.Delete; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.outbox.OutboxMessageEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import java.time.Duration; +import java.util.function.Function; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +abstract class AbstractDocumentAiTest { + + @Autowired ServiceCatalog serviceCatalog; + @Autowired PersistenceService persistenceService; + @Autowired CdsRuntime cdsRuntime; + + @BeforeEach + @AfterEach + void resetTestData() { + persistenceService.run(Delete.from(ExtractionJob_.class)); + } + + // Executes a single polling cycle using a test DIE client that returns results supplied by the + // caller. + void runPollCycle( + ExtractionService extractionService, Function jobResultFn) { + ExtractionPollingHandler handler = + new ExtractionPollingHandler( + persistenceService, + extractionService, + pollingClient(jobResultFn), + null, + cdsRuntime, + Duration.ZERO); + + OutboxMessageEventContext ctx = + EventContext.create(OutboxMessageEventContext.class, ExtractionPollingHandler.POLL_EVENT); + handler.pollExtractionJobs(ctx); + } + + private DocumentAiClient pollingClient(Function jobResultFn) { + return new DocumentAiClient() { + @Override + public String submitDocument(DocumentInput input) { + throw new UnsupportedOperationException("Submission is not supported by this test client."); + } + + @Override + public ExtractionData getJobResult(String dieJobId) { + return jobResultFn.apply(dieJobId); + } + }; + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java new file mode 100644 index 0000000..3c6eb8f --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java @@ -0,0 +1,93 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.ql.Select; +import com.sap.cds.services.Service; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class DocumentSubmissionTest extends AbstractDocumentAiTest { + + @Autowired ExtractionService extractionService; + + @Test + void submissionWithoutDieBindingCreatesJobAsPending() { + Service documentAiService = + serviceCatalog.getService(Service.class, DocumentAiService_.CDS_NAME); + + documentAiService.emit(createExtractionContext()); + + assertThat( + persistenceService.run(Select.from(ExtractionJob_.class)).listOf(ExtractionJob.class)) + .singleElement() + .satisfies( + job -> { + assertThat(job.getStatus()).isEqualTo("PENDING"); + assertThat(job.getId()).isNotNull(); + }); + } + + @Test + void submissionStoresTenantOnJob() { + ExtractionResult submission = + extractionService.triggerExtraction( + "invoice.pdf", "application/pdf", null, null, "tenant-1"); + + ExtractionJob job = + persistenceService + .run(Select.from(ExtractionJob_.class).byId(submission.internalJobId())) + .single(ExtractionJob.class); + + assertThat(job.getStatus()).isEqualTo("PENDING"); + assertThat(job.getTenantId()).isEqualTo("tenant-1"); + } + + @Test + void jobsForDifferentTenantsAreStoredIndependently() { + String jobId1 = + extractionService + .triggerExtraction("doc.pdf", "application/pdf", null, null, "tenant-a") + .internalJobId(); + String jobId2 = + extractionService + .triggerExtraction("doc.pdf", "application/pdf", null, null, "tenant-b") + .internalJobId(); + + ExtractionJob job1 = + persistenceService + .run(Select.from(ExtractionJob_.class).byId(jobId1)) + .single(ExtractionJob.class); + ExtractionJob job2 = + persistenceService + .run(Select.from(ExtractionJob_.class).byId(jobId2)) + .single(ExtractionJob.class); + + assertThat(job1.getTenantId()).isEqualTo("tenant-a"); + assertThat(job2.getTenantId()).isEqualTo("tenant-b"); + assertThat(job1.getId()).isNotEqualTo(job2.getId()); + } + + private DocumentExtractionContext createExtractionContext() { + DocumentExtraction event = DocumentExtraction.create(); + event.setFileName("test.pdf"); + event.setMimeType("application/pdf"); + event.setContent(new ByteArrayInputStream("pdf-content".getBytes(StandardCharsets.UTF_8))); + + DocumentExtractionContext ctx = DocumentExtractionContext.create(); + ctx.setData(event); + return ctx; + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java new file mode 100644 index 0000000..ab46f87 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java @@ -0,0 +1,49 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class EventEmissionTest extends AbstractDocumentAiTest { + + private static final String DIE_JOB_ID = "die-job-emit-1"; + private static final String EXTRACTION_RESULT_JSON = "{\"invoiceNumber\":\"INV-042\"}"; + + @Autowired ExtractionService extractionService; + @Autowired ExtractionResultCaptureHandler captureHandler; + + @AfterEach + void resetCapture() { + captureHandler.reset(); + } + + @Test + void pollingHandlerEmitsDocumentExtractionResultWhenJobReachesDone() { + String jobId = + extractionService + .triggerExtraction("invoice.pdf", "application/pdf", null, null, "tenant-1") + .internalJobId(); + + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + + runPollCycle( + extractionService, + dieJobId -> new ExtractionData(dieJobId, "DONE", EXTRACTION_RESULT_JSON)); + + assertThat(captureHandler.getCaptured()) + .singleElement() + .satisfies( + captured -> { + assertThat(captured.getJobId()).isEqualTo(jobId); + assertThat(captured.getExtractionResult()).isEqualTo(EXTRACTION_RESULT_JSON); + }); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java new file mode 100644 index 0000000..6ca8d7c --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java @@ -0,0 +1,39 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.exceptions.IllegalStatusTransitionException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ExtractionErrorTest extends AbstractDocumentAiTest { + + private static final String DIE_JOB_ID = "die-1"; + + @Autowired ExtractionService extractionService; + + @Test + void invalidStatusTransitionThrows() { + String jobId = + extractionService + .triggerExtraction("invoice.pdf", "application/pdf", null, null, "tenant-1") + .internalJobId(); + + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + extractionService.updateExtractionResult( + jobId, ExtractionStatus.DONE, DIE_JOB_ID, "{\"result\":\"ok\"}"); + + assertThatThrownBy( + () -> + extractionService.updateExtractionResult( + jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null)) + .isInstanceOf(IllegalStatusTransitionException.class) + .hasMessageContaining(ExtractionStatus.DONE.name()) + .hasMessageContaining(ExtractionStatus.SUBMITTED.name()); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java new file mode 100644 index 0000000..8cc0017 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java @@ -0,0 +1,153 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.ql.Select; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ExtractionLifecycleTest extends AbstractDocumentAiTest { + + private static final String DIE_JOB_ID = "die-job-1"; + private static final String EXTRACTION_RESULT_JSON = "{\"invoiceNumber\":\"INV-001\"}"; + + @Autowired ExtractionService extractionService; + @Autowired ExtractionResultCaptureHandler captureHandler; + + @AfterEach + void resetCapture() { + captureHandler.reset(); + } + + @Test + void jobAdvancesThroughLifecycleToDone() { + ExtractionResult submission = submit("invoice.pdf"); + String jobId = submission.internalJobId(); + assertThat(submission.status()).isEqualTo(ExtractionResult.Status.PENDING); + + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + extractionService.updateExtractionResult(jobId, ExtractionStatus.RUNNING, DIE_JOB_ID, null); + extractionService.updateExtractionResult( + jobId, ExtractionStatus.DONE, DIE_JOB_ID, EXTRACTION_RESULT_JSON); + + ExtractionJob job = job(jobId); + assertThat(job.getStatus()).isEqualTo(ExtractionStatus.DONE.name()); + assertThat(job.getDocumentAiJobId()).isEqualTo(DIE_JOB_ID); + assertThat(job.getExtractionResult()).isEqualTo(EXTRACTION_RESULT_JSON); + } + + @Test + void jobCanTransitionToFailed() { + String jobId = submit("bad.pdf").internalJobId(); + + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + extractionService.updateExtractionResult(jobId, ExtractionStatus.FAILED, DIE_JOB_ID, null); + + ExtractionJob job = job(jobId); + assertThat(job.getStatus()).isEqualTo(ExtractionStatus.FAILED.name()); + assertThat(job.getDocumentAiJobId()).isEqualTo(DIE_JOB_ID); + } + + @Test + void singleDocumentFullRoundTripViaPollCycle() { + String jobId = submit("invoice.pdf").internalJobId(); + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + + runPollCycle( + extractionService, + dieJobId -> new ExtractionData(dieJobId, "DONE", EXTRACTION_RESULT_JSON)); + + ExtractionJob job = job(jobId); + assertThat(job.getStatus()).isEqualTo(ExtractionStatus.DONE.name()); + assertThat(job.getExtractionResult()).isEqualTo(EXTRACTION_RESULT_JSON); + assertThat(captureHandler.getCaptured()) + .singleElement() + .satisfies( + event -> { + assertThat(event.getJobId()).isEqualTo(jobId); + assertThat(event.getExtractionResult()).isEqualTo(EXTRACTION_RESULT_JSON); + }); + } + + @Test + void twoParallelDocumentsReachDoneIndependently() { + String dieJobId1 = "die-job-parallel-1"; + String dieJobId2 = "die-job-parallel-2"; + String result1 = "{\"invoiceNumber\":\"INV-A\"}"; + String result2 = "{\"invoiceNumber\":\"INV-B\"}"; + + String jobId1 = submit("doc-a.pdf").internalJobId(); + String jobId2 = submit("doc-b.pdf").internalJobId(); + + extractionService.updateExtractionResult(jobId1, ExtractionStatus.SUBMITTED, dieJobId1, null); + extractionService.updateExtractionResult(jobId2, ExtractionStatus.SUBMITTED, dieJobId2, null); + + Map resultsByDieJobId = Map.of(dieJobId1, result1, dieJobId2, result2); + runPollCycle( + extractionService, + dieJobId -> new ExtractionData(dieJobId, "DONE", resultsByDieJobId.get(dieJobId))); + + List jobs = + persistenceService.run(Select.from(ExtractionJob_.class)).listOf(ExtractionJob.class); + assertThat(jobs) + .extracting(ExtractionJob::getStatus) + .containsOnly(ExtractionStatus.DONE.name()); + + assertThat(job(jobId1).getExtractionResult()).isEqualTo(result1); + assertThat(job(jobId2).getExtractionResult()).isEqualTo(result2); + + assertThat(captureHandler.getCaptured()) + .extracting(DocumentExtractionResult::getJobId) + .containsExactlyInAnyOrder(jobId1, jobId2); + } + + @Test + void pollCycleContinuesWhenOneJobFails() { + // one polling request fails mid-cycle — the other job must still reach DONE + String dieJobIdA = "die-job-ok"; + String dieJobIdB = "die-job-error"; + + String jobIdA = submit("doc-a.pdf").internalJobId(); + String jobIdB = submit("doc-b.pdf").internalJobId(); + + extractionService.updateExtractionResult(jobIdA, ExtractionStatus.SUBMITTED, dieJobIdA, null); + extractionService.updateExtractionResult(jobIdB, ExtractionStatus.SUBMITTED, dieJobIdB, null); + + runPollCycle( + extractionService, + dieJobId -> { + if (dieJobId.equals(dieJobIdB)) throw new RuntimeException("simulated DIE failure"); + return new ExtractionData(dieJobId, "DONE", EXTRACTION_RESULT_JSON); + }); + + assertThat(job(jobIdA).getStatus()).isEqualTo(ExtractionStatus.DONE.name()); + assertThat(job(jobIdB).getStatus()).isEqualTo(ExtractionStatus.SUBMITTED.name()); + + assertThat(captureHandler.getCaptured()) + .singleElement() + .satisfies(event -> assertThat(event.getJobId()).isEqualTo(jobIdA)); + } + + private ExtractionResult submit(String fileName) { + return extractionService.triggerExtraction(fileName, "application/pdf", null, null, "tenant-1"); + } + + private ExtractionJob job(String jobId) { + return persistenceService + .run(Select.from(ExtractionJob_.class).byId(jobId)) + .single(ExtractionJob.class); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java new file mode 100644 index 0000000..b9a4283 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java @@ -0,0 +1,36 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +@ServiceName( + value = DocumentAiService_.CDS_NAME, + type = com.sap.cds.services.cds.ApplicationService.class) +class ExtractionResultCaptureHandler implements EventHandler { + + private final List captured = new ArrayList<>(); + + @After(event = DocumentExtractionResultContext.CDS_NAME) + public void onExtractionResult(DocumentExtractionResultContext context) { + captured.add(context.getData()); + } + + public List getCaptured() { + return List.copyOf(captured); + } + + public void reset() { + captured.clear(); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java new file mode 100644 index 0000000..5f861b6 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java @@ -0,0 +1,27 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.ql.Select; +import com.sap.cds.services.Service; +import org.junit.jupiter.api.Test; + +class PluginLoadTest extends AbstractDocumentAiTest { + + @Test + void documentAiServiceIsRegisteredInCatalog() { + Service documentAiService = + serviceCatalog.getService(Service.class, DocumentAiService_.CDS_NAME); + assertThat(documentAiService).isNotNull(); + } + + @Test + void extractionJobTableIsAccessible() { + persistenceService.run(Select.from(ExtractionJob_.class).columns(ExtractionJob_::ID)); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/NonStandardKeyRecommendationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/NonStandardKeyRecommendationTest.java new file mode 100644 index 0000000..558babb --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/NonStandardKeyRecommendationTest.java @@ -0,0 +1,167 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.itest; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import com.sap.cds.feature.Application; +import com.sap.cds.feature.aicore.itest.BaseIntegrationTest; +import com.sap.cds.ql.Insert; +import com.sap.cds.services.persistence.PersistenceService; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MvcResult; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@SpringBootTest(classes = Application.class) +class NonStandardKeyRecommendationTest extends BaseIntegrationTest { + + private static final String SERVICE_PATH = "/odata/v4/RecommendationTestService"; + private static final String BOOKS_URL = SERVICE_PATH + "/BooksWithCustomKey"; + private static final String ORDER_ITEMS_URL = SERVICE_PATH + "/OrderItems"; + + @BeforeAll + void setupContextData() { + ensureRptDeploymentReady(); + + PersistenceService db = + runtime + .getServiceCatalog() + .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + db.run( + Insert.into("itest.Categories") + .entries( + List.of( + Map.of("ID", 10, "name", "Fiction"), + Map.of("ID", 20, "name", "Science"), + Map.of("ID", 30, "name", "History")))); + + db.run( + Insert.into("itest.BooksWithCustomKey") + .entries( + List.of( + Map.of("isbn", "978-0-01", "title", "Book A", "price", 10, "category_ID", 10), + Map.of("isbn", "978-0-02", "title", "Book B", "price", 20, "category_ID", 20), + Map.of( + "isbn", "978-0-03", "title", "Book C", "price", 30, "category_ID", 30)))); + + db.run( + Insert.into("itest.OrderItems") + .entries( + List.of( + Map.of( + "order_no", + 1, + "item_no", + 1, + "product", + "Widget", + "quantity", + 5, + "category_ID", + 10), + Map.of( + "order_no", + 1, + "item_no", + 2, + "product", + "Gadget", + "quantity", + 3, + "category_ID", + 20), + Map.of( + "order_no", + 2, + "item_no", + 1, + "product", + "Doohickey", + "quantity", + 7, + "category_ID", + 30)))); + } + + @Test + @WithMockUser(username = "test-user") + void customKey_readDraft_returnsSapRecommendations() throws Exception { + String isbn = createBookDraft("{\"isbn\":\"978-TEST-01\"}"); + + mockMvc + .perform(get(bookDraftUrl(isbn))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.SAP_Recommendations").exists()) + .andExpect(jsonPath("$.SAP_Recommendations.category_ID").isArray()) + .andExpect(jsonPath("$.SAP_Recommendations.category_ID").isNotEmpty()); + + deleteBookDraft(isbn); + } + + @Test + @WithMockUser(username = "test-user") + void composedKey_readDraft_returnsSapRecommendations() throws Exception { + MvcResult createResult = + mockMvc + .perform( + post(ORDER_ITEMS_URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"order_no\":99,\"item_no\":99}")) + .andExpect(status().isCreated()) + .andReturn(); + + String body = createResult.getResponse().getContentAsString(); + int orderNo = JsonPath.read(body, "$.order_no"); + int itemNo = JsonPath.read(body, "$.item_no"); + + mockMvc + .perform(get(orderItemDraftUrl(orderNo, itemNo))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.SAP_Recommendations").exists()) + .andExpect(jsonPath("$.SAP_Recommendations.category_ID").isArray()) + .andExpect(jsonPath("$.SAP_Recommendations.category_ID").isNotEmpty()); + + mockMvc.perform(delete(orderItemDraftUrl(orderNo, itemNo))).andExpect(status().isNoContent()); + } + + private String createBookDraft(String content) throws Exception { + MvcResult result = + mockMvc + .perform(post(BOOKS_URL).contentType(MediaType.APPLICATION_JSON).content(content)) + .andExpect(status().isCreated()) + .andReturn(); + + return JsonPath.read(result.getResponse().getContentAsString(), "$.isbn"); + } + + private void deleteBookDraft(String isbn) throws Exception { + mockMvc.perform(delete(bookDraftUrl(isbn))).andExpect(status().isNoContent()); + } + + private String bookDraftUrl(String isbn) { + return BOOKS_URL + "(isbn='" + isbn + "',IsActiveEntity=false)"; + } + + private String orderItemDraftUrl(int orderNo, int itemNo) { + return ORDER_ITEMS_URL + + "(order_no=" + + orderNo + + ",item_no=" + + itemNo + + ",IsActiveEntity=false)"; + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/RecommendationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/RecommendationTest.java new file mode 100644 index 0000000..cbbd1d8 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/RecommendationTest.java @@ -0,0 +1,364 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.itest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import com.sap.cds.feature.aicore.itest.BaseIntegrationTest; +import com.sap.cds.ql.Delete; +import com.sap.cds.ql.Insert; +import com.sap.cds.services.persistence.PersistenceService; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MvcResult; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RecommendationTest extends BaseIntegrationTest { + + private static final String SERVICE_PATH = "/odata/v4/RecommendationTestService"; + private static final String TASKS_URL = SERVICE_PATH + "/Tasks"; + + private static final List CATEGORY_IDS = List.of(1, 2, 3); + private static final List PRIORITY_CODES = List.of("HIGH", "MED", "LOW"); + + @BeforeAll + void setupContextData() { + ensureRptDeploymentReady(); + + PersistenceService db = + runtime + .getServiceCatalog() + .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + db.run( + Insert.into("itest.Categories") + .entries( + List.of( + Map.of("ID", 1, "name", "Development"), + Map.of("ID", 2, "name", "Testing"), + Map.of("ID", 3, "name", "Documentation")))); + + db.run( + Insert.into("itest.Priorities") + .entries( + List.of( + Map.of("code", "HIGH", "name", "High Priority"), + Map.of("code", "MED", "name", "Medium Priority"), + Map.of("code", "LOW", "name", "Low Priority")))); + + db.run( + Insert.into("itest.Tasks") + .entries( + List.of( + Map.of( + "ID", + UUID.randomUUID().toString(), + "title", + "Implement login", + "description", + "Add OAuth login flow", + "effort", + 8, + "category_ID", + 1, + "priority_code", + "HIGH"), + Map.of( + "ID", + UUID.randomUUID().toString(), + "title", + "Write unit tests", + "description", + "Cover auth module", + "effort", + 5, + "category_ID", + 2, + "priority_code", + "MED"), + Map.of( + "ID", + UUID.randomUUID().toString(), + "title", + "Update API docs", + "description", + "Document new endpoints", + "effort", + 3, + "category_ID", + 3, + "priority_code", + "LOW")))); + } + + @Test + @WithMockUser(username = "test-user") + void readDraft_returnsSapRecommendations() throws Exception { + String draftId = createDraft("{}"); + + mockMvc + .perform(get(draftUrl(draftId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.SAP_Recommendations").exists()) + .andExpect(jsonPath("$.SAP_Recommendations.category_ID").isArray()) + .andExpect(jsonPath("$.SAP_Recommendations.priority_code").isArray()); + + deleteDraft(draftId); + } + + @Test + @WithMockUser(username = "test-user") + void readActiveEntity_noRecommendations() throws Exception { + String draftId = + createDraft("{\"title\":\"Active test\",\"category_ID\":1,\"priority_code\":\"HIGH\"}"); + activateDraft(draftId); + + mockMvc + .perform(get(activeUrl(draftId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.SAP_Recommendations").doesNotExist()); + } + + @Test + @WithMockUser(username = "test-user") + void readMultipleRows_noRecommendations() throws Exception { + mockMvc + .perform(get(TASKS_URL)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value").isArray()) + .andExpect(jsonPath("$.value[0].SAP_Recommendations").doesNotExist()); + } + + @Test + @WithMockUser(username = "test-user") + void readNonDraftEntity_noRecommendations() throws Exception { + mockMvc + .perform(get("/odata/v4/TestService/Products")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value[0].SAP_Recommendations").doesNotExist()); + } + + @Test + @WithMockUser(username = "test-user") + void readDraft_allColumnsFilled_noRecommendations() throws Exception { + String draftId = createDraft("{\"category_ID\":1,\"priority_code\":\"HIGH\"}"); + + mockMvc + .perform(get(draftUrl(draftId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.SAP_Recommendations").doesNotExist()); + + deleteDraft(draftId); + } + + @Test + @WithMockUser(username = "test-user") + void readDraft_someColumnsFilled_returnsPartialRecommendations() throws Exception { + String draftId = createDraft("{\"category_ID\":2}"); + + mockMvc + .perform(get(draftUrl(draftId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.SAP_Recommendations").exists()) + .andExpect(jsonPath("$.SAP_Recommendations.priority_code").isArray()) + .andExpect(jsonPath("$.SAP_Recommendations.priority_code").isNotEmpty()) + .andExpect(jsonPath("$.SAP_Recommendations.category_ID").isEmpty()); + + deleteDraft(draftId); + } + + @Test + @WithMockUser(username = "test-user") + void recommendations_haveCorrectStructure() throws Exception { + String draftId = createDraft("{}"); + + MvcResult result = + mockMvc + .perform(get(draftUrl(draftId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.SAP_Recommendations").exists()) + .andReturn(); + + String body = result.getResponse().getContentAsString(); + + List> categoryRecs = + JsonPath.read(body, "$.SAP_Recommendations.category_ID"); + assertThat(categoryRecs).hasSize(1); + Map rec = categoryRecs.get(0); + assertThat(rec).containsKey("RecommendedFieldValue"); + assertThat(rec).containsKey("RecommendedFieldDescription"); + assertThat(rec).containsEntry("RecommendedFieldScoreValue", 0.5); + assertThat(rec).containsEntry("RecommendedFieldIsSuggestion", true); + + deleteDraft(draftId); + } + + @Test + @WithMockUser(username = "test-user") + void recommendations_integerValueParsedCorrectly() throws Exception { + String draftId = createDraft("{}"); + + MvcResult result = + mockMvc.perform(get(draftUrl(draftId))).andExpect(status().isOk()).andReturn(); + + String body = result.getResponse().getContentAsString(); + List> categoryRecs = + JsonPath.read(body, "$.SAP_Recommendations.category_ID"); + assertThat(categoryRecs).isNotEmpty(); + + Object value = categoryRecs.get(0).get("RecommendedFieldValue"); + assertThat(value).isInstanceOf(Integer.class); + assertThat(CATEGORY_IDS).contains((Integer) value); + + deleteDraft(draftId); + } + + @Test + @WithMockUser(username = "test-user") + void recommendations_stringValueParsedCorrectly() throws Exception { + String draftId = createDraft("{}"); + + MvcResult result = + mockMvc.perform(get(draftUrl(draftId))).andExpect(status().isOk()).andReturn(); + + String body = result.getResponse().getContentAsString(); + List> priorityRecs = + JsonPath.read(body, "$.SAP_Recommendations.priority_code"); + assertThat(priorityRecs).isNotEmpty(); + + Object value = priorityRecs.get(0).get("RecommendedFieldValue"); + assertThat(value).isInstanceOf(String.class); + assertThat(PRIORITY_CODES).contains((String) value); + + deleteDraft(draftId); + } + + @Test + @WithMockUser(username = "test-user") + void recommendations_textResolution_resolvesDescription() throws Exception { + String draftId = createDraft("{}"); + + MvcResult result = + mockMvc.perform(get(draftUrl(draftId))).andExpect(status().isOk()).andReturn(); + + String body = result.getResponse().getContentAsString(); + List> categoryRecs = + JsonPath.read(body, "$.SAP_Recommendations.category_ID"); + assertThat(categoryRecs).isNotEmpty(); + + String description = (String) categoryRecs.get(0).get("RecommendedFieldDescription"); + assertThat(description).isIn("Development", "Testing", "Documentation"); + + deleteDraft(draftId); + } + + @Test + @WithMockUser(username = "test-user") + void notEnoughContextRows_noRecommendations() throws Exception { + PersistenceService db = + runtime + .getServiceCatalog() + .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + db.run(Delete.from("itest.Tasks")); + + try { + String draftId = createDraft("{}"); + + mockMvc + .perform(get(draftUrl(draftId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.SAP_Recommendations").doesNotExist()); + + deleteDraft(draftId); + } finally { + db.run( + Insert.into("itest.Tasks") + .entries( + List.of( + Map.of( + "ID", + UUID.randomUUID().toString(), + "title", + "Implement login", + "description", + "Add OAuth login flow", + "effort", + 8, + "category_ID", + 1, + "priority_code", + "HIGH"), + Map.of( + "ID", + UUID.randomUUID().toString(), + "title", + "Write unit tests", + "description", + "Cover auth module", + "effort", + 5, + "category_ID", + 2, + "priority_code", + "MED"), + Map.of( + "ID", + UUID.randomUUID().toString(), + "title", + "Update API docs", + "description", + "Document new endpoints", + "effort", + 3, + "category_ID", + 3, + "priority_code", + "LOW")))); + } + } + + private String createDraft(String body) throws Exception { + MvcResult result = + mockMvc + .perform(post(TASKS_URL).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()) + .andReturn(); + + return JsonPath.read(result.getResponse().getContentAsString(), "$.ID"); + } + + private void activateDraft(String id) throws Exception { + mockMvc + .perform( + post(draftUrl(id) + "/RecommendationTestService.draftActivate") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + } + + private void deleteDraft(String id) throws Exception { + mockMvc.perform(delete(draftUrl(id))).andExpect(status().isNoContent()); + } + + private String draftUrl(String id) { + return TASKS_URL + "(ID=" + id + ",IsActiveEntity=false)"; + } + + private String activeUrl(String id) { + return TASKS_URL + "(ID=" + id + ",IsActiveEntity=true)"; + } +} diff --git a/integration-tests/spring/test-service.cds b/integration-tests/spring/test-service.cds new file mode 100644 index 0000000..f14b2c1 --- /dev/null +++ b/integration-tests/spring/test-service.cds @@ -0,0 +1,39 @@ +using {itest} from '../db/schema'; +using { AICore } from 'com.sap.cds/ai'; +using from 'com.sap.cds/sap-document-ai'; + +service TestService { + entity Products as projection on itest.Products; +} + +service RecommendationTestService @(requires: 'any') { + @odata.draft.enabled + entity Tasks as projection on itest.Tasks; + + @odata.draft.enabled + entity BooksWithCustomKey as projection on itest.BooksWithCustomKey; + + @odata.draft.enabled + entity OrderItems as projection on itest.OrderItems; + + @readonly entity Categories as projection on itest.Categories; + @readonly entity Priorities as projection on itest.Priorities; +} + +annotate RecommendationTestService.Tasks with { + category @Common.ValueListWithFixedValues; + priority @Common.ValueListWithFixedValues; +} + +annotate RecommendationTestService.Tasks with { + category @Common.Text: category.name; + priority @Common.Text: priority.name; +} + +annotate RecommendationTestService.BooksWithCustomKey with { + category @Common.ValueListWithFixedValues; +} + +annotate RecommendationTestService.OrderItems with { + category @Common.ValueListWithFixedValues; +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..75a5bc7 --- /dev/null +++ b/pom.xml @@ -0,0 +1,505 @@ + + + 4.0.0 + + com.sap.cds + cds-ai-root + ${revision} + pom + + CDS AI - Root + Aggregator for CAP Java AI plugins (recommendations, AI Core client) + https://github.com/cap-java/cds-ai + + + SAP SE + https://www.sap.com + + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + SAP SE + https://www.sap.com + + + + + cds-feature-ai-core + cds-feature-recommendations + cds-feature-sap-document-ai + cds-starter-ai + + + + scm:git:git@github.com:cap-java/cds-ai.git + scm:git:git@github.com:cap-java/cds-ai.git + https://github.com/cap-java/cds-ai + + + + + central + MavenCentral + https://central.sonatype.com + + + artifactory + Artifactory_DMZ-snapshots + https://common.repositories.cloud.sap/artifactory/cap-java + + + + + + 0.0.1-alpha + + + **/Mock*.java + + + 17 + + 4.9.0 + + + 1.19.0 + + 3.4.5 + + 0.8.15 + + 6.1.0 + + 5.23.0 + + 3.5.0 + + 3.15.0 + + 3.4.0 + + 3.1.4 + + 3.12.0 + + 3.5.6 + + 3.5.6 + + 3.28.0 + + 3.6.3 + + 1.7.3 + + 3.2.8 + + 0.11.0 + + 5.31.0 + + 3.2.4 + + 4.10.2.0 + + + true + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + com.sap.cloud.sdk + sdk-bom + ${sap.cloud.sdk.version} + pom + import + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + org.mockito + mockito-bom + ${mockito.version} + pom + import + + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + + com.sap.cds + cds-feature-ai-core + ${revision} + + + + com.sap.cds + cds-feature-recommendations + ${revision} + + + + com.sap.cds + cds-feature-sap-document-ai + ${revision} + + + + com.sap.cds + cds-feature-ai-integration-tests-spring + ${revision} + + + com.sap.cds + cds-feature-ai-integration-tests-mtx-local + ${revision} + + + + + + + com.sap.cds + cds-services-api + + + + org.junit.jupiter + junit-jupiter + test + + + + + org.assertj + assertj-core + 3.27.7 + test + + + + org.mockito + mockito-core + test + + + + org.mockito + mockito-junit-jupiter + test + + + + + + + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + maven-clean-plugin + ${maven.clean.plugin.version} + + + maven-compiler-plugin + ${maven.compiler.plugin.version} + + ${java.version} + UTF-8 + + + + maven-source-plugin + ${maven.source.plugin.version} + + + maven-deploy-plugin + ${maven.deploy.plugin.version} + + + maven-javadoc-plugin + ${maven.javadoc.plugin.version} + + + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + maven-failsafe-plugin + ${maven.failsafe.plugin.version} + + + maven-pmd-plugin + ${maven.pmd.plugin.version} + + + maven-enforcer-plugin + ${maven.enforcer.plugin.version} + + + org.codehaus.mojo + flatten-maven-plugin + ${maven.flatten.plugin.version} + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs.maven.plugin.version} + + + + + + + + org.codehaus.mojo + flatten-maven-plugin + + true + resolveCiFriendliesOnly + + + + flatten + + flatten + + process-resources + + + flatten.clean + + clean + + clean + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + Max + true + /src/main/resources/spotbugs-exclusion-filter.xml + ${project.build.directory} + true + + + + spotbugs-error + + check + + process-test-classes + + + + + + maven-pmd-plugin + + ${java.version} + 5 + ${project.build.directory} + true + true + false + false + + + /rulesets/java/maven-pmd-plugin-default.xml + + + ${project.basedir}/src/gen/java + + + **/*Test** + + + + + + com.sap.cloud.sdk.quality + pmd-rules + 3.78.0 + + + + + pmd-error + + check + cpd-check + + process-test-classes + + + + + + maven-enforcer-plugin + + + no-duplicate-declared-dependencies + + enforce + + + + + + 3.6.3 + + + ${java.version} + + + + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.6.0 + + + + + + + /* + * © $YEAR SAP SE or an SAP affiliate company and cds-ai contributors. + */ + + + + + pom.xml + + + + + + + + check + + process-sources + + + + + + + + with-integration-tests + + true + + + integration-tests + coverage-report + + + + deploy-release + + + + maven-source-plugin + + + attach-sources + + jar-no-fork + + + + + + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven.gpg.plugin.version} + + + sign-artifacts + + sign + + verify + + + + + org.sonatype.central + central-publishing-maven-plugin + ${central.publishing.plugin.version} + true + + central + + cds-feature-ai-integration-tests + cds-feature-ai-integration-tests-spring + cds-feature-ai-integration-tests-mtx-local + cds-feature-ai-coverage-report + + + + + + + + + diff --git a/samples/bookshop/.cdsrc.json b/samples/bookshop/.cdsrc.json new file mode 100644 index 0000000..a1e4536 --- /dev/null +++ b/samples/bookshop/.cdsrc.json @@ -0,0 +1,14 @@ +{ + "requires": { + "db": "hana", + "AICore": { + "model": "@cap-js/ai/srv/AICoreService" + }, + "[production]": { + "auth": "xsuaa" + } + }, + "cdsc": { + "moduleLookupDirectories": ["node_modules/", "target/cds/"] + } +} diff --git a/samples/bookshop/.gitignore b/samples/bookshop/.gitignore new file mode 100644 index 0000000..2ecc68d --- /dev/null +++ b/samples/bookshop/.gitignore @@ -0,0 +1,34 @@ +**/gen/ +**/edmx/ +*.db +*.sqlite +*.sqlite-wal +*.sqlite-shm +schema*.sql +default-env.json + +**/bin/ +**/target/ +.flattened-pom.xml +.classpath +.project +.settings + +**/node/ +**/node_modules/ + +**/.mta/ +*.mtar + +*.log* +gc_history* +hs_err* +*.tgz +*.iml + +.vscode +.idea +.reloadtrigger + +# added by cds +.cdsrc-private.json diff --git a/samples/bookshop/app/_i18n/i18n.properties b/samples/bookshop/app/_i18n/i18n.properties new file mode 100644 index 0000000..7326bbb --- /dev/null +++ b/samples/bookshop/app/_i18n/i18n.properties @@ -0,0 +1,15 @@ +Books = Books +Book = Book +ID = ID +Title = Title +Author = Author +Authors = Authors +AuthorID = Author ID +AuthorName = Author Name +Name = Name +Age = Age +Stock = Stock +Order = Order +Orders = Orders +Price = Price +Genre = Genre \ No newline at end of file diff --git a/samples/bookshop/app/_i18n/i18n_de.properties b/samples/bookshop/app/_i18n/i18n_de.properties new file mode 100644 index 0000000..cb712c1 --- /dev/null +++ b/samples/bookshop/app/_i18n/i18n_de.properties @@ -0,0 +1,15 @@ +Books = Bücher +Book = Buch +ID = ID +Title = Titel +Author = Autor +Authors = Autoren +AuthorID = ID des Autors +AuthorName = Name des Autors +Name = Name +Age = Alter +Stock = Bestand +Order = Bestellung +Orders = Bestellungen +Price = Preis +Genre = Genre \ No newline at end of file diff --git a/samples/bookshop/app/admin-books/fiori-service.cds b/samples/bookshop/app/admin-books/fiori-service.cds new file mode 100644 index 0000000..34ffe51 --- /dev/null +++ b/samples/bookshop/app/admin-books/fiori-service.cds @@ -0,0 +1,79 @@ +using {AdminService} from '../../srv/admin-service.cds'; + +//////////////////////////////////////////////////////////////////////////// +// +// Books Object Page +// +annotate AdminService.Books with @(UI: { + HeaderInfo : { + TypeName : '{i18n>Book}', + TypeNamePlural: '{i18n>Books}', + Title : {Value: title}, + Description : {Value: author.name} + }, + Facets : [ + { + $Type : 'UI.ReferenceFacet', + Label : '{i18n>General}', + Target: '@UI.FieldGroup#General' + }, + { + $Type : 'UI.ReferenceFacet', + Label : '{i18n>Details}', + Target: '@UI.FieldGroup#Details' + }, + { + $Type : 'UI.ReferenceFacet', + Label : '{i18n>Admin}', + Target: '@UI.FieldGroup#Admin' + } + ], + FieldGroup #General: {Data: [ + {Value: title}, + {Value: author_ID}, + {Value: genre_ID}, + {Value: descr} + ]}, + FieldGroup #Details: {Data: [ + {Value: stock}, + {Value: price}, + {Value: currency_code} + ]}, + FieldGroup #Admin : {Data: [ + {Value: createdBy}, + {Value: createdAt}, + {Value: modifiedBy}, + {Value: modifiedAt} + ]} +}); + + +//////////////////////////////////////////////////////////// +// +// Draft for Localized Data +// +annotate sap.capire.bookshop.Books with @fiori.draft.enabled; +annotate AdminService.Books with @odata.draft.enabled; + +// In addition we need to expose Languages through AdminService as a target for ValueList +using {sap} from '@sap/cds/common'; + +extend service AdminService { + @readonly + entity Languages as projection on sap.common.Languages; +} + +// Workaround for Fiori popup for asking user to enter a new UUID on Create +annotate AdminService.Books with { + ID @Core.Computed; +} + +// Show Genre as drop down, not a dialog +annotate AdminService.Books with { + genre @Common.ValueListWithFixedValues; +} + +// Show currency also as drop down, not a dialog +annotate AdminService.Books with { + currency @Common.ValueListWithFixedValues; +} diff --git a/samples/bookshop/app/admin-books/webapp/Component.js b/samples/bookshop/app/admin-books/webapp/Component.js new file mode 100644 index 0000000..e98677e --- /dev/null +++ b/samples/bookshop/app/admin-books/webapp/Component.js @@ -0,0 +1,8 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("books.Component", { + metadata: { manifest: "json" } + }); +}); + +/* eslint no-undef:0 */ diff --git a/samples/bookshop/app/admin-books/webapp/i18n/i18n.properties b/samples/bookshop/app/admin-books/webapp/i18n/i18n.properties new file mode 100644 index 0000000..9a23ee4 --- /dev/null +++ b/samples/bookshop/app/admin-books/webapp/i18n/i18n.properties @@ -0,0 +1,3 @@ +appTitle=Manage Books +appSubTitle=Manage bookshop inventory +appDescription=Manage your bookshop inventory with ease. diff --git a/samples/bookshop/app/admin-books/webapp/i18n/i18n_de.properties b/samples/bookshop/app/admin-books/webapp/i18n/i18n_de.properties new file mode 100644 index 0000000..01d56a2 --- /dev/null +++ b/samples/bookshop/app/admin-books/webapp/i18n/i18n_de.properties @@ -0,0 +1,3 @@ +appTitle=Bücher verwalten +appSubTitle=Verwalten Sie den Bestand der Buchhandlungen +appDescription=Verwalten Sie den Bestand Ihrer Buchhandlung ganz einfach. diff --git a/samples/bookshop/app/admin-books/webapp/manifest.json b/samples/bookshop/app/admin-books/webapp/manifest.json new file mode 100644 index 0000000..4bcc54c --- /dev/null +++ b/samples/bookshop/app/admin-books/webapp/manifest.json @@ -0,0 +1,145 @@ +{ + "_version": "1.49.0", + "sap.app": { + "applicationVersion": { + "version": "1.0.0" + }, + "id": "bookshop.admin-books", + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "i18n": "i18n/i18n.properties", + "dataSources": { + "AdminService": { + "uri": "/odata/v4/AdminService/", + "type": "OData", + "settings": { + "odataVersion": "4.0" + } + } + }, + "crossNavigation": { + "inbounds": { + "intent-Books-manage": { + "signature": { + "parameters": {}, + "additionalParameters": "allowed" + }, + "semanticObject": "Books", + "action": "manage" + } + } + } + }, + "sap.ui": { + "technology": "UI5", + "fullWidth": false, + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.115.1", + "libs": { + "sap.fe.templates": {} + } + }, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "uri": "i18n/i18n.properties" + }, + "": { + "dataSource": "AdminService", + "settings": { + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true, + "groupProperties": { + "default": { + "submit": "Auto" + } + } + } + } + }, + "routing": { + "routes": [ + { + "pattern": ":?query:", + "name": "BooksList", + "target": "BooksList" + }, + { + "pattern": "Books({key}):?query:", + "name": "BooksDetails", + "target": "BooksDetails" + }, + { + "pattern": "Books({key}/author({key2}):?query:", + "name": "AuthorsDetails", + "target": "AuthorsDetails" + } + ], + "targets": { + "BooksList": { + "type": "Component", + "id": "BooksList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/Books", + "initialLoad": true, + "navigation": { + "Books": { + "detail": { + "route": "BooksDetails" + } + } + } + } + } + }, + "BooksDetails": { + "type": "Component", + "id": "BooksDetailsList", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/Books", + "editableHeaderContent": false, + "navigation": { + "Authors": { + "detail": { + "route": "AuthorsDetails" + } + } + } + } + } + }, + "AuthorsDetails": { + "type": "Component", + "id": "AuthorsDetailsList", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/Authors" + } + } + } + } + }, + "contentDensities": { + "compact": true, + "cozy": true + } + }, + "sap.fiori": { + "registrationIds": [], + "archeType": "transactional" + } +} diff --git a/samples/bookshop/app/appconfig/fioriSandboxConfig.json b/samples/bookshop/app/appconfig/fioriSandboxConfig.json new file mode 100644 index 0000000..32d1a07 --- /dev/null +++ b/samples/bookshop/app/appconfig/fioriSandboxConfig.json @@ -0,0 +1,117 @@ +{ + "services": { + "LaunchPage": { + "adapter": { + "config": { + "catalogs": [], + "groups": [ + { + "id": "Bookshop", + "title": "Bookshop", + "isPreset": true, + "isVisible": true, + "isGroupLocked": false, + "tiles": [ + { + "id": "BrowseBooks", + "tileType": "sap.ushell.ui.tile.StaticTile", + "properties": { + "title": "Browse Books", + "targetURL": "#Books-display" + } + } + ] + }, + { + "id": "Administration", + "title": "Administration", + "isPreset": true, + "isVisible": true, + "isGroupLocked": false, + "tiles": [ + { + "id": "ManageBooks", + "tileType": "sap.ushell.ui.tile.StaticTile", + "properties": { + "title": "Manage Books", + "targetURL": "#Books-manage" + } + }, + { + "id": "ManageInvoices", + "tileType": "sap.ushell.ui.tile.StaticTile", + "properties": { + "title": "Supplier Invoices", + "targetURL": "#SupplierInvoices-manage" + } + } + ] + } + ] + } + } + }, + "NavTargetResolution": { + "config": { + "enableClientSideTargetResolution": true + } + }, + "ClientSideTargetResolution": { + "adapter": { + "config": { + "inbounds": { + "BrowseBooks": { + "semanticObject": "Books", + "action": "display", + "title": "Browse Books", + "signature": { + "parameters": { + "Books.ID": { + "renameTo": "ID" + }, + "Authors.books.ID": { + "renameTo": "ID" + } + }, + "additionalParameters": "ignored" + }, + "resolutionResult": { + "applicationType": "SAPUI5", + "additionalInformation": "SAPUI5.Component=bookshop", + "url": "browse/webapp" + } + }, + "ManageBooks": { + "semanticObject": "Books", + "action": "manage", + "title": "Manage Books", + "signature": { + "parameters": {}, + "additionalParameters": "allowed" + }, + "resolutionResult": { + "applicationType": "SAPUI5", + "additionalInformation": "SAPUI5.Component=books", + "url": "admin-books/webapp" + } + }, + "ManageInvoices": { + "semanticObject": "SupplierInvoices", + "action": "manage", + "title": "Supplier Invoices", + "signature": { + "parameters": {}, + "additionalParameters": "allowed" + }, + "resolutionResult": { + "applicationType": "SAPUI5", + "additionalInformation": "SAPUI5.Component=supplierinvoices", + "url": "supplier-invoices/webapp" + } + } + } + } + } + } + } +} diff --git a/samples/bookshop/app/browse/fiori-service.cds b/samples/bookshop/app/browse/fiori-service.cds new file mode 100644 index 0000000..666781d --- /dev/null +++ b/samples/bookshop/app/browse/fiori-service.cds @@ -0,0 +1,58 @@ +using {CatalogService} from '../../srv/cat-service.cds'; + +//////////////////////////////////////////////////////////////////////////// +// +// Books Object Page +// +annotate CatalogService.Books with @(UI: { + HeaderInfo : { + TypeName : '{i18n>Book}', + TypeNamePlural: '{i18n>Books}', + Title : {Value: title}, + Description : {Value: author} + }, + Facets : [{ + $Type : 'UI.CollectionFacet', + Label : '{i18n>Details}', + Facets: [ + { + $Type : 'UI.ReferenceFacet', + Target: '@UI.FieldGroup#Descr' + }, + { + $Type : 'UI.ReferenceFacet', + Target: '@UI.FieldGroup#Price' + } + ] + }], + FieldGroup #Descr : {Data: [{Value: descr, ![@UI.MultiLineText]: true}]}, + FieldGroup #Price: {Data: [ + {Value: price}, + {Value: currency_code} + ]} +}); + +//////////////////////////////////////////////////////////////////////////// +// +// Books List Page +// +annotate CatalogService.Books with @(UI: { + SelectionFields: [ + ID, + price, + currency_code + ], + LineItem : [ + { + Value: ID, + Label: '{i18n>Title}' + }, + { + Value: author, + Label: '{i18n>Author}' + }, + {Value: genre.name}, + {Value: price}, + {Value: currency_code} + ] +}); diff --git a/samples/bookshop/app/browse/webapp/Component.js b/samples/bookshop/app/browse/webapp/Component.js new file mode 100644 index 0000000..4020679 --- /dev/null +++ b/samples/bookshop/app/browse/webapp/Component.js @@ -0,0 +1,7 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function(AppComponent) { + "use strict"; + return AppComponent.extend("bookshop.Component", { + metadata: { manifest: "json" } + }); +}); +/* eslint no-undef:0 */ diff --git a/samples/bookshop/app/browse/webapp/i18n/i18n.properties b/samples/bookshop/app/browse/webapp/i18n/i18n.properties new file mode 100644 index 0000000..21436e8 --- /dev/null +++ b/samples/bookshop/app/browse/webapp/i18n/i18n.properties @@ -0,0 +1,3 @@ +appTitle=Browse Books +appSubTitle=Find all your favorite books +appDescription=This application lets you find the next books you want to read. diff --git a/samples/bookshop/app/browse/webapp/i18n/i18n_de.properties b/samples/bookshop/app/browse/webapp/i18n/i18n_de.properties new file mode 100644 index 0000000..ea86c3f --- /dev/null +++ b/samples/bookshop/app/browse/webapp/i18n/i18n_de.properties @@ -0,0 +1,3 @@ +appTitle=Bücher anschauen +appSubTitle=Finden sie ihre nächste Lektüre +appDescription=Finden Sie die nachsten Bücher, die Sie lesen möchten. diff --git a/samples/bookshop/app/browse/webapp/manifest.json b/samples/bookshop/app/browse/webapp/manifest.json new file mode 100644 index 0000000..cd4b1c3 --- /dev/null +++ b/samples/bookshop/app/browse/webapp/manifest.json @@ -0,0 +1,137 @@ +{ + "_version": "1.49.0", + "sap.app": { + "id": "bookshop.browse", + "applicationVersion": { + "version": "1.0.0" + }, + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "i18n": "i18n/i18n.properties", + "dataSources": { + "CatalogService": { + "uri": "/odata/v4/CatalogService/", + "type": "OData", + "settings": { + "odataVersion": "4.0" + } + } + }, + "crossNavigation": { + "inbounds": { + "intent1": { + "signature": { + "parameters": { + "Books.ID": { + "renameTo": "ID" + }, + "Authors.books.ID": { + "renameTo": "ID" + } + }, + "additionalParameters": "ignored" + }, + "semanticObject": "Books", + "action": "display", + "title": "{{appTitle}}", + "subTitle": "{{appSubTitle}}", + "icon": "sap-icon://course-book", + "indicatorDataSource": { + "dataSource": "CatalogService", + "path": "Books/$count", + "refresh": 1800 + } + } + } + } + }, + "sap.ui": { + "technology": "UI5", + "fullWidth": false, + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.115.1", + "libs": { + "sap.fe.templates": {} + } + }, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "uri": "i18n/i18n.properties" + }, + "": { + "dataSource": "CatalogService", + "settings": { + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true, + "groupProperties": { + "default": { + "submit": "Auto" + } + } + } + } + }, + "routing": { + "routes": [ + { + "pattern": ":?query:", + "name": "BooksList", + "target": "BooksList" + }, + { + "pattern": "Books({key}):?query:", + "name": "BooksDetails", + "target": "BooksDetails" + } + ], + "targets": { + "BooksList": { + "type": "Component", + "id": "BooksList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/Books", + "initialLoad": true, + "navigation": { + "Books": { + "detail": { + "route": "BooksDetails" + } + } + } + } + } + }, + "BooksDetails": { + "type": "Component", + "id": "BooksDetailsList", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/Books" + } + } + } + } + }, + "contentDensities": { + "compact": true, + "cozy": true + } + }, + "sap.fiori": { + "registrationIds": [], + "archeType": "transactional" + } +} diff --git a/samples/bookshop/app/common.cds b/samples/bookshop/app/common.cds new file mode 100644 index 0000000..2afc6e0 --- /dev/null +++ b/samples/bookshop/app/common.cds @@ -0,0 +1,263 @@ +/* + Common Annotations shared by all apps +*/ + +using {sap.capire.bookshop as my} from '../db/schema'; +using { + sap.common, + sap.common.Currencies +} from '@sap/cds/common'; + +//////////////////////////////////////////////////////////////////////////// +// +// Books Lists +// +annotate my.Books with @( + Common.SemanticKey: [ID], + UI : { + Identification : [{Value: title}], + SelectionFields: [ + ID, + author_ID, + price, + currency_code + ], + LineItem : [ + { + Value: ID, + Label: '{i18n>Title}' + }, + { + Value: author.ID, + Label: '{i18n>Author}' + }, + {Value: genre.name}, + {Value: stock}, + {Value: price} + ] + } +) { + ID @Common : { + SemanticObject : 'Books', + Text : title, + TextArrangement: #TextOnly + }; + author @ValueList.entity: 'Authors'; +}; + +annotate Currencies with { + symbol @Common.Label: '{i18n>Currency}'; +} + +//////////////////////////////////////////////////////////////////////////// +// +// Books Elements +// +annotate my.Books with { + ID @title: '{i18n>ID}'; + title @title: '{i18n>Title}'; + genre @title: '{i18n>Genre}' @Common : { + Text : genre.name, + TextArrangement: #TextOnly + }; + author @title: '{i18n>Author}' @Common : { + Text : author.name, + TextArrangement: #TextOnly + }; + price @title: '{i18n>Price}'; + currency @title: '{i18n>Currency}'; + stock @title: '{i18n>Stock}'; + descr @title: '{i18n>Description}' @UI.MultiLineText; +} + +//////////////////////////////////////////////////////////////////////////// +// +// Genres List +// +annotate my.Genres with @( + Common.SemanticKey: [name], + UI : { + SelectionFields: [name], + LineItem : [ + {Value: name}, + { + Value: parent.name, + Label: 'Main Genre' + } + ] + } +); + +annotate my.Genres with { + ID @Common.Text: name @Common.TextArrangement: #TextOnly; +} + +//////////////////////////////////////////////////////////////////////////// +// +// Genre Details +// +annotate my.Genres with @(UI: { + Identification: [{Value: name}], + HeaderInfo : { + TypeName : '{i18n>Genre}', + TypeNamePlural: '{i18n>Genres}', + Title : {Value: name}, + Description : {Value: ID} + }, + Facets : [{ + $Type : 'UI.ReferenceFacet', + Label : '{i18n>SubGenres}', + Target: 'children/@UI.LineItem' + }] +}); + +//////////////////////////////////////////////////////////////////////////// +// +// Genres Elements +// +annotate my.Genres with { + ID @title: '{i18n>ID}'; + name @title: '{i18n>Genre}'; +} + +//////////////////////////////////////////////////////////////////////////// +// +// Authors List +// +annotate my.Authors with @( + Common.SemanticKey: [ID], + UI : { + Identification : [{Value: name}], + SelectionFields: [name], + LineItem : [ + {Value: ID}, + {Value: dateOfBirth}, + {Value: dateOfDeath}, + {Value: placeOfBirth}, + {Value: placeOfDeath} + ] + } +) { + ID @Common: { + SemanticObject : 'Authors', + Text : name, + TextArrangement: #TextOnly + }; +}; + +//////////////////////////////////////////////////////////////////////////// +// +// Author Details +// +annotate my.Authors with @(UI: { + HeaderInfo: { + TypeName : '{i18n>Author}', + TypeNamePlural: '{i18n>Authors}', + Title : {Value: name}, + Description : {Value: dateOfBirth} + }, + Facets : [{ + $Type : 'UI.ReferenceFacet', + Target: 'books/@UI.LineItem' + }] +}); + + +//////////////////////////////////////////////////////////////////////////// +// +// Authors Elements +// +annotate my.Authors with { + ID @title: '{i18n>ID}'; + name @title: '{i18n>Name}'; + dateOfBirth @title: '{i18n>DateOfBirth}'; + dateOfDeath @title: '{i18n>DateOfDeath}'; + placeOfBirth @title: '{i18n>PlaceOfBirth}'; + placeOfDeath @title: '{i18n>PlaceOfDeath}'; +} + +//////////////////////////////////////////////////////////////////////////// +// +// Languages List +// +annotate common.Languages with @( + Common.SemanticKey: [code], + Identification : [{Value: code}], + UI : { + SelectionFields: [ + name, + descr + ], + LineItem : [ + {Value: code}, + {Value: name} + ] + } +); + +//////////////////////////////////////////////////////////////////////////// +// +// Language Details +// +annotate common.Languages with @(UI: { + HeaderInfo : { + TypeName : '{i18n>Language}', + TypeNamePlural: '{i18n>Languages}', + Title : {Value: name}, + Description : {Value: descr} + }, + Facets : [{ + $Type : 'UI.ReferenceFacet', + Label : '{i18n>Details}', + Target: '@UI.FieldGroup#Details' + }], + FieldGroup #Details: {Data: [ + {Value: code}, + {Value: name}, + {Value: descr} + ]} +}); + +//////////////////////////////////////////////////////////////////////////// +// +// Currencies List +// +annotate common.Currencies with @( + Common.SemanticKey: [code], + Identification : [{Value: code}], + UI : { + SelectionFields: [ + name, + descr + ], + LineItem : [ + {Value: descr}, + {Value: symbol}, + {Value: code} + ] + } +); + +//////////////////////////////////////////////////////////////////////////// +// +// Currency Details +// +annotate common.Currencies with @(UI: { + HeaderInfo : { + TypeName : '{i18n>Currency}', + TypeNamePlural: '{i18n>Currencies}', + Title : {Value: descr}, + Description : {Value: code} + }, + Facets : [{ + $Type : 'UI.ReferenceFacet', + Label : '{i18n>Details}', + Target: '@UI.FieldGroup#Details' + }], + FieldGroup #Details: {Data: [ + {Value: name}, + {Value: symbol}, + {Value: code}, + {Value: descr} + ]} +}); diff --git a/samples/bookshop/app/index.html b/samples/bookshop/app/index.html new file mode 100644 index 0000000..70f6315 --- /dev/null +++ b/samples/bookshop/app/index.html @@ -0,0 +1,32 @@ + + + + + + + + Bookshop + + + + + + + + + + diff --git a/samples/bookshop/app/package.json b/samples/bookshop/app/package.json new file mode 100644 index 0000000..73938f8 --- /dev/null +++ b/samples/bookshop/app/package.json @@ -0,0 +1,11 @@ +{ + "name": "bookshop-app", + "version": "1.0.0", + "description": "Bookshop Approuter", + "scripts": { + "start": "node node_modules/@sap/approuter/approuter.js" + }, + "dependencies": { + "@sap/approuter": "^16.0.0" + } +} diff --git a/samples/bookshop/app/services.cds b/samples/bookshop/app/services.cds new file mode 100644 index 0000000..cbea7a0 --- /dev/null +++ b/samples/bookshop/app/services.cds @@ -0,0 +1,7 @@ +/* + This model controls what gets served to Fiori frontends... +*/ +using from './common'; +using from './browse/fiori-service'; +using from './admin-books/fiori-service'; +using from './supplier-invoices/fiori-service'; diff --git a/samples/bookshop/app/supplier-invoices/fiori-service.cds b/samples/bookshop/app/supplier-invoices/fiori-service.cds new file mode 100644 index 0000000..fa54eea --- /dev/null +++ b/samples/bookshop/app/supplier-invoices/fiori-service.cds @@ -0,0 +1,88 @@ +using SupplierInvoicesService as service from '../../srv/supplier-invoices-service'; + +//////////////////////////////////////////////////////////////////////////// +// +// Supplier Invoices List Page +// +annotate service.SupplierInvoices with @(UI: { + SelectionFields: [ + invoiceNumber, + supplier_ID, + status_code + ], + LineItem : [ + {Value: invoiceNumber, Label: '{i18n>InvoiceNumber}'}, + {Value: supplier.name, Label: '{i18n>Supplier}'}, + {Value: invoiceDate, Label: '{i18n>InvoiceDate}'}, + {Value: totalAmount, Label: '{i18n>TotalAmount}'}, + {Value: currency_code, Label: '{i18n>Currency}'}, + {Value: status.name, Label: '{i18n>Status}'} + ] +}); + +//////////////////////////////////////////////////////////////////////////// +// +// Supplier Invoices Object Page +// +annotate service.SupplierInvoices with @(UI: { + HeaderInfo: { + TypeName : '{i18n>SupplierInvoice}', + TypeNamePlural: '{i18n>SupplierInvoices}', + Title : {Value: invoiceNumber}, + Description : {Value: supplier.name} + }, + Identification: [{Value: invoiceNumber}], + Facets : [ + { + $Type : 'UI.ReferenceFacet', + ID : 'InvoiceDetails', + Label : '{i18n>InvoiceDetails}', + Target: '@UI.FieldGroup#Details' + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : '{i18n>Attachments}', + Target: 'attachments/@UI.LineItem' + } + ], + FieldGroup #Details: {Data: [ + {Value: invoiceNumber, Label: '{i18n>InvoiceNumber}'}, + {Value: invoiceDate, Label: '{i18n>InvoiceDate}'}, + {Value: supplier_ID, Label: '{i18n>Supplier}'}, + {Value: totalAmount, Label: '{i18n>TotalAmount}'}, + {Value: currency_code, Label: '{i18n>Currency}'}, + {Value: status_code, Label: '{i18n>Status}'} + ]} +}); + +// Action button on Object Page header +annotate service.SupplierInvoices with @( + UI.Identification : [ + { + $Type : 'UI.DataFieldForAction', + Action : 'SupplierInvoicesService.extractInvoiceData', + Label : 'Extract Invoice Data' + } + ] +); + +annotate service.SupplierInvoices with { + supplier @Common: { + Text : supplier.name, + TextArrangement: #TextOnly, + ValueList : { + CollectionPath: 'Suppliers', + Parameters : [{ + $Type : 'Common.ValueListParameterInOut', + ValueListProperty: 'ID', + LocalDataProperty: supplier_ID + }] + } + }; + status @Common: { + Text : status.name, + TextArrangement : #TextOnly, + ValueListWithFixedValues: true + }; +} diff --git a/samples/bookshop/app/supplier-invoices/webapp/Component.js b/samples/bookshop/app/supplier-invoices/webapp/Component.js new file mode 100644 index 0000000..a3d3fb0 --- /dev/null +++ b/samples/bookshop/app/supplier-invoices/webapp/Component.js @@ -0,0 +1,6 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("supplierinvoices.Component", { + metadata: { manifest: "json" } + }); +}); diff --git a/samples/bookshop/app/supplier-invoices/webapp/i18n/i18n.properties b/samples/bookshop/app/supplier-invoices/webapp/i18n/i18n.properties new file mode 100644 index 0000000..6741d50 --- /dev/null +++ b/samples/bookshop/app/supplier-invoices/webapp/i18n/i18n.properties @@ -0,0 +1,12 @@ +appTitle=Supplier Invoices +appDescription=Manage supplier invoices and extract data from PDF documents +InvoiceNumber=Invoice Number +Supplier=Supplier +InvoiceDate=Invoice Date +TotalAmount=Total Amount +Currency=Currency +Status=Status +InvoiceDetails=Invoice Details +Attachments=Attachments +SupplierInvoice=Supplier Invoice +SupplierInvoices=Supplier Invoices diff --git a/samples/bookshop/app/supplier-invoices/webapp/i18n/i18n_de.properties b/samples/bookshop/app/supplier-invoices/webapp/i18n/i18n_de.properties new file mode 100644 index 0000000..f6d3f83 --- /dev/null +++ b/samples/bookshop/app/supplier-invoices/webapp/i18n/i18n_de.properties @@ -0,0 +1,12 @@ +appTitle=Lieferantenrechnungen +appDescription=Lieferantenrechnungen verwalten und Daten aus PDF-Dokumenten extrahieren +InvoiceNumber=Rechnungsnummer +Supplier=Lieferant +InvoiceDate=Rechnungsdatum +TotalAmount=Gesamtbetrag +Currency=Waehrung +Status=Status +InvoiceDetails=Rechnungsdetails +Attachments=Anlagen +SupplierInvoice=Lieferantenrechnung +SupplierInvoices=Lieferantenrechnungen diff --git a/samples/bookshop/app/supplier-invoices/webapp/manifest.json b/samples/bookshop/app/supplier-invoices/webapp/manifest.json new file mode 100644 index 0000000..116a92a --- /dev/null +++ b/samples/bookshop/app/supplier-invoices/webapp/manifest.json @@ -0,0 +1,123 @@ +{ + "_version": "1.49.0", + "sap.app": { + "applicationVersion": { + "version": "1.0.0" + }, + "id": "bookshop.supplier-invoices", + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "i18n": "i18n/i18n.properties", + "dataSources": { + "SupplierInvoicesService": { + "uri": "/odata/v4/SupplierInvoicesService/", + "type": "OData", + "settings": { + "odataVersion": "4.0" + } + } + }, + "crossNavigation": { + "inbounds": { + "intent-SupplierInvoices-manage": { + "signature": { + "parameters": {}, + "additionalParameters": "allowed" + }, + "semanticObject": "SupplierInvoices", + "action": "manage" + } + } + } + }, + "sap.ui": { + "technology": "UI5", + "fullWidth": false, + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.115.1", + "libs": { + "sap.fe.templates": {} + } + }, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "uri": "i18n/i18n.properties" + }, + "": { + "dataSource": "SupplierInvoicesService", + "settings": { + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true, + "groupProperties": { + "default": { + "submit": "Auto" + } + } + } + } + }, + "routing": { + "routes": [ + { + "pattern": ":?query:", + "name": "InvoicesList", + "target": "InvoicesList" + }, + { + "pattern": "SupplierInvoices({key}):?query:", + "name": "InvoiceDetails", + "target": "InvoiceDetails" + } + ], + "targets": { + "InvoicesList": { + "type": "Component", + "id": "InvoicesList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/SupplierInvoices", + "initialLoad": true, + "navigation": { + "SupplierInvoices": { + "detail": { + "route": "InvoiceDetails" + } + } + } + } + } + }, + "InvoiceDetails": { + "type": "Component", + "id": "InvoiceDetails", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/SupplierInvoices", + "editableHeaderContent": false + } + } + } + } + }, + "contentDensities": { + "compact": true, + "cozy": true + } + }, + "sap.fiori": { + "registrationIds": [], + "archeType": "transactional" + } +} diff --git a/samples/bookshop/db/data/sap-common-Currencies.csv b/samples/bookshop/db/data/sap-common-Currencies.csv new file mode 100644 index 0000000..71ec0af --- /dev/null +++ b/samples/bookshop/db/data/sap-common-Currencies.csv @@ -0,0 +1,204 @@ +code,name,descr,minorUnit +"ADP","Peseta","Andorran Peseta --> (Old --> EUR)",0 +"AED","Dirham","United Arab Emirates Dirham",2 +"AFA","Afghani","Afghani (Old)",0 +"AFN","Afghani","Afghani",2 +"ALL","Lek","Albanian Lek",2 +"AMD","Dram","Armenian Dram",2 +"ANG","W.Ind.Guilder","West Indian Guilder",2 +"AOA","Kwansa","Angolanische Kwanza",2 +"AON","New Kwanza","Angolan New Kwanza (Old)",0 +"AOR","Kwanza Reajust.","Angolan Kwanza Reajustado (Old)",0 +"ARS","Arg. Peso","Argentine Peso",2 +"ATS","Shilling","Austrian Schilling (Old --> EUR)",2 +"AUD","Austr. Dollar","Australian Dollar",2 +"AWG","Aruban Florin","Aruban Florin",2 +"AZM","Manat","Azerbaijani Manat (Old)",2 +"AZN","Manat","Azerbaijani Manat",2 +"BAM","Convert. Mark","Bosnia and Herzegovina Convertible Mark",2 +"BBD","Dollar","Barbados Dollar",2 +"BDT","Taka","Bangladesh Taka",2 +"BEF","Belgian Franc","Belgian Franc (Old --> EUR)",0 +"BGN","Lev","Bulgarian Lev",2 +"BHD","Dinar","Bahraini Dinar",3 +"BIF","Burundi Franc","Burundi Franc",0 +"BMD","Bermudan Dollar","Bermudan Dollar",2 +"BND","Dollar","Brunei Dollar",2 +"BOB","Boliviano","Boliviano",2 +"BRL","Real","Brazilian Real",2 +"BSD","Dollar","Bahaman Dollar",2 +"BTN","Ngultrum","Bhutan Ngultrum",2 +"BWP","Pula","Botswana Pula",2 +"BYB","Belarus. Ruble","Belarusian Ruble (Old)",0 +"BYN","Bela. Ruble N.","Belarusian Ruble (New)",2 +"BYR","Ruble","Belarusian Ruble",2 +"BZD","Dollar","Belize Dollar",2 +"CAD","Canadian Dollar","Canadian Dollar",2 +"CDF","Congolese Franc","Congolese Franc",2 +"CHF","Swiss Franc","Swiss Franc",2 +"CLP","Peso","Chilean Peso",0 +"CNY","Renminbi","Chinese Renminbi",2 +"COP","Peso","Colombian Peso",2 +"CRC","Cost.Rica Colon","Costa Rica Colon",2 +"CSD","Serbian Dinar","Serbian Dinar (Old)",2 +"CUC","Peso Convertib.","Peso Convertible",2 +"CVE","Escudo","Cape Verde Escudo",2 +"CYP","Cyprus Pound","Cyprus Pound (Old --> EUR)",2 +"CZK","Krona","Czech Krona",2 +"DEM","German Mark","German Mark (Old --> EUR)",2 +"DJF","Djibouti Franc","Djibouti Franc",0 +"DKK","Danish Krone","Danish Krone",2 +"DOP","Dominican Peso","Dominican Peso",2 +"DZD","Dinar","Algerian Dinar",2 +"ECS","Sucre","Ecuadorian Sucre (Old --> USD)",0 +"EEK","Krona","Estonian Krone (Old --> EUR)",2 +"EGP","Pound","Egyptian Pound",2 +"ERN","Nakfa","Eritrean Nafka",2 +"ESP","Peseta","Spanish Peseta (Old --> EUR)",0 +"ETB","Birr","Ethiopian Birr",2 +"EUR","Euro","European Euro",2 +"FIM","Finnish markka","Finnish Markka (Old --> EUR)",2 +"FJD","Dollar","Fiji Dollar",2 +"FKP","Falkland Pound","Falkland Pound",2 +"FRF","French Franc","French Franc (Old --> EUR)",2 +"GBP","Pound","British Pound",2 +"GEL","Lari","Georgian Lari",2 +"GHC","Cedi","Ghanaian Cedi (Old)",2 +"GHS","Cedi","Ghanian Cedi",2 +"GIP","Gibraltar Pound","Gibraltar Pound",2 +"GMD","Dalasi","Gambian Dalasi",2 +"GNF","Franc","Guinean Franc",0 +"GRD","Drachma","Greek Drachma (Old --> EUR)",0 +"GTQ","Quetzal","Guatemalan Quetzal",2 +"GWP","Guinea Peso","Guinea Peso (Old --> SHP)",2 +"GYD","Guyana Dollar","Guyana Dollar",2 +"HKD","H.K.Dollar","Hong Kong Dollar",2 +"HNL","Lempira","Honduran Lempira",2 +"HRK","Kuna","Croatian Kuna (Old --> EUR)",2 +"HTG","Gourde","Haitian Gourde",2 +"HUF","Forint","Hungarian Forint",2 +"IDR","Rupiah","Indonesian Rupiah",2 +"IEP","Irish Punt","Irish Punt (Old --> EUR)",2 +"ILS","Scheckel","Israeli Scheckel",2 +"INR","Rupee","Indian Rupee",2 +"IQD","Dinar","Iraqui Dinar",2 +"ISK","Krona","Iceland Krona",0 +"ITL","Lire","Italian Lira (Old --> EUR)",0 +"JMD","Jamaican Dollar","Jamaican Dollar",2 +"JOD","Jordanian Dinar","Jordanian Dinar",3 +"JPY","Yen","Japanese Yen",0 +"KES","Shilling","Kenyan Shilling",2 +"KGS","Som","Kyrgyzstan Som",2 +"KHR","Riel","Cambodian Riel",2 +"KMF","Comoros Franc","Comoros Franc",0 +"KRW","S.Korean Won","South Korean Won",0 +"KWD","Dinar","Kuwaiti Dinar",3 +"KYD","Cayman Dollar","Cayman Dollar",2 +"KZT","Tenge","Kazakstanian Tenge",2 +"LAK","Kip","Laotian Kip",2 +"LBP","Lebanese Pound","Lebanese Pound",2 +"LKR","Sri Lanka Rupee","Sri Lankan Rupee",2 +"LRD","Liberian Dollar","Liberian Dollar",2 +"LSL","Loti","Lesotho Loti",2 +"LTL","Lita","Lithuanian Lita",2 +"LUF","Lux. Franc","Luxembourg Franc (Old --> EUR)",0 +"LVL","Lat","Latvian Lat",2 +"LYD","Libyan Dinar","Libyan Dinar",3 +"MAD","Dirham","Moroccan Dirham",2 +"MDL","Leu","Moldavian Leu",2 +"MGA","Madagasc.Ariary","Madagascan Ariary",2 +"MGF","Madagascan Fr.","Madagascan Franc (Old",0 +"MKD","Maced. Denar","Macedonian Denar",2 +"MMK","Kyat","Myanmar Kyat",2 +"MNT","Tugrik","Mongolian Tugrik",2 +"MOP","Pataca","Macao Pataca",2 +"MRO","Ouguiya","Mauritanian Ouguiya",2 +"MRU","Ouguiya","Mauritanian Ouguiya",2 +"MTL","Lira","Maltese Lira (Old --> EUR)",2 +"MUR","Rupee","Mauritian Rupee",2 +"MVR","Rufiyaa","Maldive Rufiyaa",2 +"MWK","Malawi Kwacha","Malawi Kwacha",2 +"MXN","Peso","Mexican Pesos",2 +"MYR","Ringgit","Malaysian Ringgit",2 +"MZM","Metical","Mozambique Metical (Old)",0 +"MZN","Metical","Mozambique Metical",2 +"NAD","Namibian Dollar","Namibian Dollar",2 +"NGN","Naira","Nigerian Naira",2 +"NIO","Cordoba Oro","Nicaraguan Cordoba Oro",2 +"NLG","Guilder","Dutch Guilder (Old --> EUR)",2 +"NOK","Norwegian Krone","Norwegian Krone",2 +"NPR","Rupee","Nepalese Rupee",2 +"NZD","N.Zeal.Dollars","New Zealand Dollars",2 +"OMR","Omani Rial","Omani Rial",3 +"PAB","Balboa","Panamanian Balboa",2 +"PEN","New Sol","Peruvian New Sol",2 +"PGK","Kina","Papua New Guinea Kina",2 +"PHP","Peso","Philippine Peso",2 +"PKR","Rupee","Pakistani Rupee",2 +"PLN","Zloty","Polish Zloty (new)",2 +"PTE","Escudo","Portuguese Escudo (Old --> EUR)",0 +"PYG","Guarani","Paraguayan Guarani",0 +"QAR","Rial","Qatar Rial",2 +"ROL","Leu (Old)","Romanian Leu (Old)",0 +"RON","Leu","Romanian Leu",2 +"RSD","Serbian Dinar","Serbian Dinar",2 +"RUB","Ruble","Russian Ruble",2 +"RWF","Franc","Rwandan Franc",0 +"SAR","Rial","Saudi Riyal",2 +"SBD","Sol.Isl.Dollar","Solomon Islands Dollar",2 +"SCR","Rupee","Seychelles Rupee",2 +"SDD","Dinar","Sudanese Dinar (Old)",2 +"SDG","Pound","Sudanese Pound",2 +"SDP","Pound","Sudanese Pound (until 1992)",2 +"SEK","Swedish Krona","Swedish Krona",2 +"SGD","Sing.Dollar","Singapore Dollar",2 +"SHP","St.Helena Pound","St.Helena Pound",2 +"SIT","Tolar","Slovenian Tolar (Old --> EUR)",2 +"SKK","Krona","Slovakian Krona (Old --> EUR)",2 +"SLL","Leone","Sierra Leone Leone",2 +"SOS","Shilling","Somalian Shilling",2 +"SRD","Surinam Doillar","Surinam Dollar",2 +"SRG","Surinam Guilder","Surinam Guilder (Old)",2 +"SSP","Pound","South Sudanese Pound",2 +"STD","Dobra","Sao Tome / Principe Dobra",2 +"SVC","Colon","El Salvador Colon",2 +"SZL","Lilangeni","Swaziland Lilangeni",2 +"THB","Baht","Thailand Baht",2 +"TJR","Ruble","Tajikistani Ruble (Old)",0 +"TJS","Somoni","Tajikistani Somoni",2 +"TMM","Manat (Old)","Turkmenistani Manat (Old)",0 +"TMT","Manat","Turkmenistani Manat",2 +"TND","Dinar","Tunisian Dinar",3 +"TOP","Pa'anga","Tongan Pa'anga",2 +"TPE","Timor Escudo","Timor Escudo --> USD",2 +"TRL","Lira (Old)","Turkish Lira (Old)",0 +"TRY","Lira","Turkish Lira",2 +"TTD","T.+ T. Dollar","Trinidad and Tobago Dollar",2 +"TWD","Dollar","New Taiwan Dollar",2 +"TZS","Shilling","Tanzanian Shilling",2 +"UAH","Hryvnia","Ukraine Hryvnia",2 +"UGX","Shilling","Ugandan Shilling",0 +"USD","US Dollar","United States Dollar",2 +"UYU","Peso","Uruguayan Peso",2 +"UZS","Total","Uzbekistan Som",2 +"VEB","Bolivar (Old)","Venezuelan Bolivar (Old)",2 +"VEF","Bolivar","Venezuelan Bolivar",2 +"VES","Bolivar","Venezuelan Bolivar",2 +"VND","Dong","Vietnamese Dong",0 +"VUV","Vatu","Vanuatu Vatu",2 +"WST","Tala","Samoan Tala",2 +"XAF","CFA Franc BEAC","Gabon CFA Franc BEAC",0 +"XCD","Dollar","East Carribean Dollar",2 +"XEU","E.C.U.","European Currency Unit (E.C.U.)",2 +"XOF","CFA Franc BCEAO","Benin CFA Franc BCEAO",0 +"XPF","Fr. Franc (Pac)","French Franc (Pacific Islands)",0 +"YER","Yemeni Ryal","Yemeni Ryal",2 +"YUM","New Dinar","New Yugoslavian Dinar (Old)",2 +"ZAR","Rand","South African Rand",2 +"ZMK","Kwacha","Zambian Kwacha (Old)",2 +"ZMW","Kwacha","Zambian Kwacha (New)",2 +"ZRN","Zaire","Zaire (Old)",2 +"ZWD","Zimbabwe Dollar","Zimbabwean Dollar (Old)",2 +"ZWL","Zimbabwe Dollar","Zimbabwean Dollar (New)",2 +"ZWN","Zimbabwe Dollar","Zimbabwean Dollar (Old)",2 +"ZWR","Zimbabwe Dollar","Zimbabwean Dollar (Old)",2 \ No newline at end of file diff --git a/samples/bookshop/db/data/sap.capire.bookshop-Authors.csv b/samples/bookshop/db/data/sap.capire.bookshop-Authors.csv new file mode 100644 index 0000000..5272ee1 --- /dev/null +++ b/samples/bookshop/db/data/sap.capire.bookshop-Authors.csv @@ -0,0 +1,5 @@ +ID;name;dateOfBirth;placeOfBirth;dateOfDeath;placeOfDeath +10fef92e-975f-4c41-8045-c58e5c27a040;Emily Brontë;1818-07-30;Thornton, Yorkshire;1848-12-19;Haworth, Yorkshire +d4585e0e-ab3b-4424-b2ac-f2bfa785f068;Charlotte Brontë;1818-04-21;Thornton, Yorkshire;1855-03-31;Haworth, Yorkshire +4cf60975-300d-4dbe-8598-57b02e62bae2;Edgar Allen Poe;1809-01-19;Boston, Massachusetts;1849-10-07;Baltimore, Maryland +df9fb9fa-f121-45b5-8be5-8ff7ad5219a2;Richard Carpenter;1929-08-14;King’s Lynn, Norfolk;2012-02-26;Hertfordshire, England diff --git a/samples/bookshop/db/data/sap.capire.bookshop-Books.csv b/samples/bookshop/db/data/sap.capire.bookshop-Books.csv new file mode 100644 index 0000000..5de367b --- /dev/null +++ b/samples/bookshop/db/data/sap.capire.bookshop-Books.csv @@ -0,0 +1,10 @@ +ID;title;descr;author_ID;stock;price;currency_code;genre_ID +b0056977-4cf5-46a2-ab14-6409ee2e0df1;Jane Eyre;"Jane Eyre /ɛər/ (originally published as Jane Eyre: An Autobiography) is a novel by English writer Charlotte Brontë, published under the pen name ""Currer Bell"", on 16 October 1847, by Smith, Elder & Co. of London. The first American edition was published the following year by Harper & Brothers of New York. Primarily a bildungsroman, Jane Eyre follows the experiences of its eponymous heroine, including her growth to adulthood and her love for Mr. Rochester, the brooding master of Thornfield Hall. The novel revolutionised prose fiction in that the focus on Jane's moral and spiritual development is told through an intimate, first-person narrative, where actions and events are coloured by a psychological intensity. The book contains elements of social criticism, with a strong sense of Christian morality at its core and is considered by many to be ahead of its time because of Jane's individualistic character and how the novel approaches the topics of class, sexuality, religion and feminism.";d4585e0e-ab3b-4424-b2ac-f2bfa785f068;11;12.34;GBP;11 +c7641340-a9be-4673-8dad-785a2505f46e;The Raven;"""The Raven"" is a narrative poem by American writer Edgar Allan Poe. First published in January 1845, the poem is often noted for its musicality, stylized language, and supernatural atmosphere. It tells of a talking raven's mysterious visit to a distraught lover, tracing the man's slow fall into madness. The lover, often identified as being a student, is lamenting the loss of his love, Lenore. Sitting on a bust of Pallas, the raven seems to further distress the protagonist with its constant repetition of the word ""Nevermore"". The poem makes use of folk, mythological, religious, and classical references.";4cf60975-300d-4dbe-8598-57b02e62bae2;333;13.13;USD;16 +7756b725-cefc-43a2-a3c8-0c9104a349b8;Eleonora;"""Eleonora"" is a short story by Edgar Allan Poe, first published in 1842 in Philadelphia in the literary annual The Gift. It is often regarded as somewhat autobiographical and has a relatively ""happy"" ending.";4cf60975-300d-4dbe-8598-57b02e62bae2;555;14;USD;16 +a009c640-434a-4542-ac68-51b400c880ea;Catweazle;Catweazle is a British fantasy television series, starring Geoffrey Bayldon in the title role, and created by Richard Carpenter for London Weekend Television. The first series, produced and directed by Quentin Lawrence, was screened in the UK on ITV in 1970. The second series, directed by David Reid and David Lane, was shown in 1971. Each series had thirteen episodes, most but not all written by Carpenter, who also published two books based on the scripts.;df9fb9fa-f121-45b5-8be5-8ff7ad5219a2;22;150;JPY;10 +b0056977-4cf5-46a2-ab14-6409ee2e0df2;Jane Eyre II;"Jane Eyre /ɛər/ (originally published as Jane Eyre: An Autobiography) is a novel by English writer Charlotte Brontë, published under the pen name ""Currer Bell"", on 16 October 1847, by Smith, Elder & Co. of London. The first American edition was published the following year by Harper & Brothers of New York. Primarily a bildungsroman, Jane Eyre follows the experiences of its eponymous heroine, including her growth to adulthood and her love for Mr. Rochester, the brooding master of Thornfield Hall. The novel revolutionised prose fiction in that the focus on Jane's moral and spiritual development is told through an intimate, first-person narrative, where actions and events are coloured by a psychological intensity. The book contains elements of social criticism, with a strong sense of Christian morality at its core and is considered by many to be ahead of its time because of Jane's individualistic character and how the novel approaches the topics of class, sexuality, religion and feminism.";d4585e0e-ab3b-4424-b2ac-f2bfa785f068;11;12.34;GBP; +c7641340-a9be-4673-8dad-785a2505f46f;The Raven II;"""The Raven"" is a narrative poem by American writer Edgar Allan Poe. First published in January 1845, the poem is often noted for its musicality, stylized language, and supernatural atmosphere. It tells of a talking raven's mysterious visit to a distraught lover, tracing the man's slow fall into madness. The lover, often identified as being a student, is lamenting the loss of his love, Lenore. Sitting on a bust of Pallas, the raven seems to further distress the protagonist with its constant repetition of the word ""Nevermore"". The poem makes use of folk, mythological, religious, and classical references.";4cf60975-300d-4dbe-8598-57b02e62bae2;333;13.13;USD; +7756b725-cefc-43a2-a3c8-0c9104a349b9;Eleonora II;"""Eleonora"" is a short story by Edgar Allan Poe, first published in 1842 in Philadelphia in the literary annual The Gift. It is often regarded as somewhat autobiographical and has a relatively ""happy"" ending.";4cf60975-300d-4dbe-8598-57b02e62bae2;555;14;USD; +a009c640-434a-4542-ac68-51b400c880eb;Catweazle II;Catweazle is a British fantasy television series, starring Geoffrey Bayldon in the title role, and created by Richard Carpenter for London Weekend Television. The first series, produced and directed by Quentin Lawrence, was screened in the UK on ITV in 1970. The second series, directed by David Reid and David Lane, was shown in 1971. Each series had thirteen episodes, most but not all written by Carpenter, who also published two books based on the scripts.;df9fb9fa-f121-45b5-8be5-8ff7ad5219a2;22;150;JPY; +a009c640-434a-4542-ac68-51b400c880ec;Catweazle III;Catweazle is a British fantasy television series, starring Geoffrey Bayldon in the title role, and created by Richard Carpenter for London Weekend Television. The first series, produced and directed by Quentin Lawrence, was screened in the UK on ITV in 1970. The second series, directed by David Reid and David Lane, was shown in 1971. Each series had thirteen episodes, most but not all written by Carpenter, who also published two books based on the scripts.;df9fb9fa-f121-45b5-8be5-8ff7ad5219a2;22;150;; diff --git a/samples/bookshop/db/data/sap.capire.bookshop-Genres.csv b/samples/bookshop/db/data/sap.capire.bookshop-Genres.csv new file mode 100644 index 0000000..ebeb7ce --- /dev/null +++ b/samples/bookshop/db/data/sap.capire.bookshop-Genres.csv @@ -0,0 +1,17 @@ +ID;parent_ID;name +10;;Fiction +11;10;Drama +12;10;Poetry +13;10;Fantasy +14;10;Science Fiction +15;10;Romance +16;10;Mystery +17;10;Thriller +18;10;Dystopia +19;10;Fairy Tale +20;;Non-Fiction +21;20;Biography +22;21;Autobiography +23;20;Essay +24;20;Speech + diff --git a/samples/bookshop/db/data/sap.capire.bookshop-InvoiceStatus.csv b/samples/bookshop/db/data/sap.capire.bookshop-InvoiceStatus.csv new file mode 100644 index 0000000..470cfad --- /dev/null +++ b/samples/bookshop/db/data/sap.capire.bookshop-InvoiceStatus.csv @@ -0,0 +1,6 @@ +code;name;descr +DRAFT;Draft;Invoice created but not yet submitted for extraction +EXTRACTING;Extracting;Document submitted to SAP Document AI for extraction +EXTRACTED;Extracted;Extraction completed successfully - review the results +VERIFIED;Verified;Invoice data verified and accepted by the user +FAILED;Failed;Extraction failed - check the document and retry diff --git a/samples/bookshop/db/data/sap.capire.bookshop-Suppliers.csv b/samples/bookshop/db/data/sap.capire.bookshop-Suppliers.csv new file mode 100644 index 0000000..4baf974 --- /dev/null +++ b/samples/bookshop/db/data/sap.capire.bookshop-Suppliers.csv @@ -0,0 +1,5 @@ +ID;name;country;email +b1e2c3d4-5f6a-7b8c-9d0e-1f2a3b4c5d6e;Springer Nature;DE;orders@springernature.com +c2f3d4e5-6a7b-8c9d-0e1f-2a3b4c5d6e7f;O'Reilly Media;US;invoices@oreilly.com +d3a4b5c6-7d8e-9f0a-1b2c-3d4e5f6a7b8c;Elsevier;NL;ap@elsevier.com +e4b5c6d7-8e9f-0a1b-2c3d-4e5f6a7b8c9d;Pearson Education;GB;finance@pearson.com diff --git a/samples/bookshop/db/package.json b/samples/bookshop/db/package.json new file mode 100644 index 0000000..65912cd --- /dev/null +++ b/samples/bookshop/db/package.json @@ -0,0 +1,14 @@ +{ + "name": "deploy", + "dependencies": { + "hdb": "^0", + "@sap/hdi-deploy": "^5" + }, + "engines": { + "node": "^22.0.0" + }, + "scripts": { + "start": "node node_modules/@sap/hdi-deploy/deploy.js --use-hdb --parameter com.sap.hana.di.table/try_fast_table_migration=true", + "build": "npm i && npx cds build .. --for hana --production" + } +} diff --git a/samples/bookshop/db/schema.cds b/samples/bookshop/db/schema.cds new file mode 100644 index 0000000..c13c016 --- /dev/null +++ b/samples/bookshop/db/schema.cds @@ -0,0 +1,64 @@ +using { + managed, + cuid, + sap.common.CodeList +} from '@sap/cds/common'; +using { Currency } from '@sap/cds-common-content'; + +namespace sap.capire.bookshop; + +@cds.search: { title , title_embedding } +entity Books : managed, cuid { + @Search.fuzzinessThreshold: 0.5 + @mandatory title : String(111); + descr : String(1111); + author : Association to Authors; + genre : Association to Genres; + stock : Integer; + price : Decimal; + currency : Currency; +} + +entity Authors : managed, cuid { + @mandatory name : String(111); + dateOfBirth : Date; + dateOfDeath : Date; + placeOfBirth : String; + placeOfDeath : String; + books : Association to many Books + on books.author = $self; +} + +/** Hierarchically organized Code List for Genres */ +@cds.odata.valuelist +entity Genres : CodeList { + key ID : Integer; + parent : Association to Genres; + children : Composition of many Genres + on children.parent = $self; +} + +// --- Procurement domain (Document AI showcase) --- + +entity Suppliers : managed, cuid { + @mandatory name : String(200); + country : String(3); + email : String(200); + invoices : Association to many SupplierInvoices + on invoices.supplier = $self; +} + +@cds.odata.valuelist +entity InvoiceStatus : CodeList { + key code : String(20); +} + +entity SupplierInvoices : managed, cuid { + invoiceNumber : String(50); + invoiceDate : Date; + supplier : Association to Suppliers; + totalAmount : Decimal; + currency : Currency; + status : Association to InvoiceStatus; + documentAiJobId : String; +} diff --git a/samples/bookshop/mta.yaml b/samples/bookshop/mta.yaml new file mode 100644 index 0000000..1a7a3a8 --- /dev/null +++ b/samples/bookshop/mta.yaml @@ -0,0 +1,87 @@ +_schema-version: 3.3.0 +ID: ai-bookshop +version: 1.0.0-SNAPSHOT +description: "A simple CAP project." +parameters: + enable-parallel-deployments: true +modules: + - name: ai-bookshop + type: approuter.nodejs + path: app/ + parameters: + keep-existing-routes: true + requires: + - name: srv-api + group: destinations + properties: + name: srv-api + url: ~{srv-url} + forwardAuthToken: true + - name: ai-bookshop-auth + provides: + - name: app-api + properties: + app-protocol: ${protocol} + app-uri: ${default-uri} + url: ${default-url} + - name: ai-bookshop-srv + type: java + path: srv + parameters: + instances: 1 + buildpack: sap_java_buildpack_jakarta + properties: + SPRING_PROFILES_ACTIVE: cloud,sandbox + JBP_CONFIG_COMPONENTS: "jres: ['com.sap.xs.java.buildpack.jre.SAPMachineJRE']" + JBP_CONFIG_SAP_MACHINE_JRE: '{ version: 17.+ }' + build-parameters: + builder: custom + commands: + - mvn clean package -DskipTests=true --batch-mode + build-result: target/*-exec.jar + provides: + - name: srv-api # required by consumers of CAP services (e.g. approuter) + properties: + srv-url: ${default-url} + requires: + - name: ai-bookshop-auth + - name: ai-bookshop-db + - name: joule-cap-proxy + + - name: ai-bookshop-db-deployer + type: hdb + path: db + parameters: + buildpack: nodejs_buildpack + build-parameters: + builder: custom + commands: + - npm run build + requires: + - name: ai-bookshop-db + +resources: + - name: ai-bookshop-auth + type: org.cloudfoundry.managed-service + parameters: + service: xsuaa + service-plan: application + path: ./xs-security.json + config: + xsappname: ai-bookshop-${org}-${space} + tenant-mode: dedicated + oauth2-configuration: + redirect-uris: + - https://*~{app-api/app-uri}/** + requires: + - name: app-api + - name: ai-bookshop-db + type: com.sap.xs.hdi-container + parameters: + service: hana + service-plan: hdi-shared + - name: joule-cap-proxy + type: org.cloudfoundry.existing-service + parameters: + service: aicore + service-plan: sap-internal diff --git a/samples/bookshop/package-lock.json b/samples/bookshop/package-lock.json new file mode 100644 index 0000000..42b2bd4 --- /dev/null +++ b/samples/bookshop/package-lock.json @@ -0,0 +1,2855 @@ +{ + "name": "bookshop-cds", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bookshop-cds", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@cap-js/ai": "^1", + "@sap/cds": "^9", + "@sap/cds-common-content": "^1.4.0" + }, + "devDependencies": { + "@sap/cds-dk": "^9.3.2" + } + }, + "node_modules/@cap-js/ai": { + "version": "1.0.1", + "resolved": "https://int.repositories.cloud.sap/artifactory/api/npm/build-releases-npm/@cap-js/ai/-/ai-1.0.1.tgz", + "integrity": "sha512-QE5JZTvbptGpcpSy+KgSn6IwQuB7ugmNWQ0dpX7OwXjB5MIn2utKKjrkWy0Gs1O0mEJEARl6w519UtZiY30ufQ==", + "license": "Apache-2.0", + "workspaces": [ + "tests/*" + ], + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=9" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@sap/cds": { + "version": "9.9.2", + "resolved": "https://registry.npmjs.org/@sap/cds/-/cds-9.9.2.tgz", + "integrity": "sha512-8YmZVsTRaJX+MPeZuhkvvKIgIcmYVHUG98+c8trPNdaf5+eo051w/3y2NoPmXQjr/I7y1195k/TS7kjZlUWzBg==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/cds-compiler": "^6.4", + "@sap/cds-fiori": "^2", + "express": "^4.22.1 || ^5", + "yaml": "^2" + }, + "bin": { + "cds-deploy": "bin/deploy.js", + "cds-serve": "bin/serve.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@eslint/js": "^9 || ^10", + "tar": "^7.5.6" + }, + "peerDependenciesMeta": { + "tar": { + "optional": true + } + } + }, + "node_modules/@sap/cds-common-content": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@sap/cds-common-content/-/cds-common-content-1.4.0.tgz", + "integrity": "sha512-dpZ7FIgkUof7MNkthE59UyUAUlsGe6OKjDgSFQbPKGm1yx6OP9njvpC6Q0w3dyBbzroGjcBEWCiNmarMrVqlRw==", + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=7.7.0" + } + }, + "node_modules/@sap/cds-compiler": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/@sap/cds-compiler/-/cds-compiler-6.9.3.tgz", + "integrity": "sha512-8fEeM6D059ruqLWoJqzFSNLkGoUCMnycx7Zv4OP4nA9zZeHzFC3nAe6s3ak2kASSiub1xAMMPzDE/9EnSXQKLw==", + "license": "SEE LICENSE IN LICENSE", + "bin": { + "cdsc": "bin/cdsc.js", + "cdshi": "bin/cdshi.js", + "cdsse": "bin/cdsse.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sap/cds-dk": { + "version": "9.9.3", + "resolved": "https://registry.npmjs.org/@sap/cds-dk/-/cds-dk-9.9.3.tgz", + "integrity": "sha512-XQAtj8g3NR8CNtrZ+eUB0aZNj+hAccWu7h8fE2ViB2oI4tLF6Li3SZSeRP+sDa5zz7hKi6W8XN9R8DgzCy1A4g==", + "dev": true, + "hasShrinkwrap": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@cap-js/asyncapi": "^1.0.0", + "@cap-js/openapi": "^1.0.0", + "@sap/cds": "^9", + "@sap/cds-mtxs": "^3", + "@sap/hdi-deploy": "^5", + "express": "^4.22.1 || ^5", + "hdb": "^2.0.0", + "livereload-js": "^4.0.1", + "mustache": "^4.0.1", + "ws": "^8.4.2", + "xml-js": "^1.6.11", + "yaml": "^2" + }, + "bin": { + "cds": "bin/cds.js", + "cds-ts": "bin/cds-ts.js", + "cds-tsx": "bin/cds-tsx.js" + }, + "optionalDependencies": { + "@cap-js/sqlite": "^1 || ^2" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/asyncapi": { + "version": "1.1.0", + "integrity": "sha512-ywO5NfGwoeEx6/jneOMpNMs4PFmuueet8HOEg13VAiXpu4W5juAZQ/Jv2xE0QcW9m1qiCmI1ymypmz7UI+YGbw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/db-service": { + "version": "2.11.0", + "integrity": "sha512-sl33LcxZYAJgMCQZDw4lMGe4kWYq6685Xc6ze4qcoM+rd6aqiyVsSC6C7XH5yerXs7cVHhRC+Dgo8AsaapFzlQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "generic-pool": "^3.9.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/openapi": { + "version": "1.5.0", + "integrity": "sha512-JDzENNC80vnP9W31Skz6PfgPnpaxNTgIP8CtDK3bJ0FCbr5sXshRqes1pc17kNMXDbJAtEshS0dN7GYBW2THWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "pluralize": "^8.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/sqlite": { + "version": "2.4.0", + "integrity": "sha512-Ao+AzIN6BWHNpLbGxAzF79OezFNHzDG2srwiBABs0FYxIxEGkc2hg6ETo79pTTt66gcWtx7pWh/N9xk2M6SFBQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@cap-js/db-service": "^2.11.0", + "better-sqlite3": "^12.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8", + "sql.js": "^1.13.0" + }, + "peerDependenciesMeta": { + "sql.js": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@eslint/js": { + "version": "10.0.1", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds": { + "version": "9.9.1", + "integrity": "sha512-GqdsBsRkZThhpOyzj8ihf/jDmf/2zprZFgaun6ZymUw4/ahzjK/bbdd6eQ8txDuv88pnUl2HPFjvUVq3O/6hCA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/cds-compiler": "^6.4", + "@sap/cds-fiori": "^2", + "express": "^4.22.1 || ^5", + "yaml": "^2" + }, + "bin": { + "cds-deploy": "bin/deploy.js", + "cds-serve": "bin/serve.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@eslint/js": "^9 || ^10", + "tar": "^7.5.6" + }, + "peerDependenciesMeta": { + "tar": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-compiler": { + "version": "6.9.3", + "integrity": "sha512-8fEeM6D059ruqLWoJqzFSNLkGoUCMnycx7Zv4OP4nA9zZeHzFC3nAe6s3ak2kASSiub1xAMMPzDE/9EnSXQKLw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "bin": { + "cdsc": "bin/cdsc.js", + "cdshi": "bin/cdshi.js", + "cdsse": "bin/cdsse.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-fiori": { + "version": "2.3.0", + "integrity": "sha512-6oWov+DSpFrSTgxXR0dZhak6aZ/IVRZvaHERMi0EgSTzIJdlvZlpw3Kf18ePMcTrRrtEXwD4RIjKt8pbs0g2Hg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-mtxs": { + "version": "3.9.4", + "integrity": "sha512-A3FRCGCMNhml8MLpGv+OUHwDhAUgvAiE38qdTL87/KQ932CTNZ1frohQtT24iszoamB7NI8lp9RR3BFD6HR/FQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/hdi-deploy": "^5" + }, + "bin": { + "cds-mtx": "bin/cds-mtx.js", + "cds-mtx-migrate": "bin/cds-mtx-migrate.js" + }, + "peerDependencies": { + "@sap/cds": ">=9" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi": { + "version": "4.8.1", + "integrity": "sha512-LfRtIbtfvMl82a8CwpqPJRRB9hRSVUQyvxualYBEzyNu/phuw6bFGQA5LHVngHsms+y797Icm4tl4X+TEkTtlg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "async": "^3.2.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.5", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi-deploy": { + "version": "5.7.0", + "integrity": "sha512-ENBCtCBnJEKBlvC5mQT/G7OAteOENo7ygSeRJgVdz7AHikcHUxegtavXkJtFCJ/PSjpjytFKfGRmG8peUVAmNQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/hdi": "^4.8.1", + "@sap/xsenv": "^6.0.0", + "async": "^3.2.6", + "dotenv": "^16.4.5", + "handlebars": "^4.7.9", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=18.x" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.6", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/xsenv": { + "version": "6.2.1", + "integrity": "sha512-R1p7VdD3N3jvdkL8av4vLqF+cTQihTz9mCqqF+oa9rVZvgLaCb4ODyZ1dln5/fBgg1OSuch0ESxu3AqZrXVknw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "debug": "4.4.3", + "node-cache": "^5.1.2", + "verror": "1.10.1" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/accepts": { + "version": "2.0.0", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/assert-plus": { + "version": "1.0.0", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/async": { + "version": "3.2.6", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/base64-js": { + "version": "1.5.1", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/better-sqlite3": { + "version": "12.11.1", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/@sap/cds-dk/node_modules/bindings": { + "version": "1.5.0", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/bl": { + "version": "4.1.0", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/body-parser": { + "version": "2.3.0", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/braces": { + "version": "3.0.3", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/buffer": { + "version": "5.7.1", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/@sap/cds-dk/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/chownr": { + "version": "1.1.4", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/clone": { + "version": "2.1.2", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-disposition": { + "version": "1.1.0", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie-signature": { + "version": "1.2.2", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/core-util-is": { + "version": "1.0.2", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/decompress-response": { + "version": "6.0.0", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/deep-extend": { + "version": "0.6.0", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/detect-libc": { + "version": "2.1.2", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/dotenv": { + "version": "16.6.1", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sap/cds-dk/node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/end-of-stream": { + "version": "1.4.5", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-object-atoms": { + "version": "1.1.2", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/etag": { + "version": "1.8.1", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/expand-template": { + "version": "2.0.3", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/express": { + "version": "5.2.1", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/extsprintf": { + "version": "1.4.1", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/file-uri-to-path": { + "version": "1.0.0", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/fill-range": { + "version": "7.1.1", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/finalhandler": { + "version": "2.1.1", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/fresh": { + "version": "2.0.0", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/fs-constants": { + "version": "1.0.0", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/generic-pool": { + "version": "3.9.0", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/github-from-package": { + "version": "0.0.0", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/handlebars": { + "version": "4.7.9", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/hasown": { + "version": "2.0.4", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb": { + "version": "2.29.4", + "integrity": "sha512-9L7/xjx4NU2YWatqLSHXhuEIuvNlV/4eRFg7u/L63U1Dc5LocP4yWeH8UGKTLBCJkt548OQ+A7PqqimZwEuqdw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "iconv-lite": "0.7.0" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "lz4-wasm-nodejs": "0.9.2" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb/node_modules/iconv-lite": { + "version": "0.7.0", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/iconv-lite": { + "version": "0.7.2", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/ieee754": { + "version": "1.2.1", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ini": { + "version": "1.3.8", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-number": { + "version": "7.0.0", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-promise": { + "version": "4.0.0", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/livereload-js": { + "version": "4.0.2", + "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/lz4-wasm-nodejs": { + "version": "0.9.2", + "integrity": "sha512-hSwgJPS98q/Oe/89Y1OxzeA/UdnASG8GvldRyKa7aZyoAFCC8VPRtViBSava7wWC66WocjUwBpWau2rEmyFPsw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/math-intrinsics": { + "version": "1.1.0", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/media-typer": { + "version": "1.1.0", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/merge-descriptors": { + "version": "2.0.0", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch": { + "version": "4.0.8", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-types": { + "version": "3.0.2", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/mimic-response": { + "version": "3.1.0", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/minimist": { + "version": "1.2.8", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/mkdirp-classic": { + "version": "0.5.3", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/mustache": { + "version": "4.2.0", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/@sap/cds-dk/node_modules/napi-build-utils": { + "version": "2.0.0", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/negotiator": { + "version": "1.0.0", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/neo-async": { + "version": "2.6.2", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/node-abi": { + "version": "3.92.0", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/node-cache": { + "version": "5.1.2", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/object-inspect": { + "version": "1.13.4", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/on-finished": { + "version": "2.4.1", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/once": { + "version": "1.4.0", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/@sap/cds-dk/node_modules/parseurl": { + "version": "1.3.3", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/path-to-regexp": { + "version": "8.4.2", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/pluralize": { + "version": "8.0.0", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sap/cds-dk/node_modules/prebuild-install": { + "version": "7.1.3", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/proxy-addr": { + "version": "2.0.7", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/pump": { + "version": "3.0.4", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/@sap/cds-dk/node_modules/qs": { + "version": "6.15.3", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/raw-body": { + "version": "3.0.2", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/rc": { + "version": "1.2.8", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/readable-stream": { + "version": "3.6.2", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sap/cds-dk/node_modules/router": { + "version": "2.2.0", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sap/cds-dk/node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/sax": { + "version": "1.6.0", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/semver": { + "version": "7.8.5", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/send": { + "version": "1.2.1", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/serve-static": { + "version": "2.2.1", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/side-channel": { + "version": "1.1.1", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-weakmap": { + "version": "1.0.2", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/simple-concat": { + "version": "1.0.1", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/simple-get": { + "version": "4.0.1", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/string_decoder": { + "version": "1.3.0", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/strip-json-comments": { + "version": "2.0.1", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-fs": { + "version": "2.1.4", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-stream": { + "version": "2.2.0", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/to-regex-range": { + "version": "5.0.1", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/tunnel-agent": { + "version": "0.6.0", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@sap/cds-dk/node_modules/type-is": { + "version": "2.1.0", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/uglify-js": { + "version": "3.19.3", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/util-deprecate": { + "version": "1.0.2", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/vary": { + "version": "1.1.2", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/verror": { + "version": "1.10.1", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/wordwrap": { + "version": "1.0.0", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/wrappy": { + "version": "1.0.2", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ws": { + "version": "8.21.0", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/xml-js": { + "version": "1.6.11", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/yaml": { + "version": "2.9.0", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@sap/cds-fiori": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sap/cds-fiori/-/cds-fiori-2.3.0.tgz", + "integrity": "sha512-6oWov+DSpFrSTgxXR0dZhak6aZ/IVRZvaHERMi0EgSTzIJdlvZlpw3Kf18ePMcTrRrtEXwD4RIjKt8pbs0g2Hg==", + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=8" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/samples/bookshop/package.json b/samples/bookshop/package.json new file mode 100644 index 0000000..3d1ab66 --- /dev/null +++ b/samples/bookshop/package.json @@ -0,0 +1,25 @@ +{ + "name": "bookshop-cds", + "version": "1.0.0", + "description": "Generated by cds-services-archetype", + "license": "ISC", + "repository": "", + "devDependencies": { + "@sap/cds-dk": "^9.3.2" + }, + "dependencies": { + "@cap-js/ai": "^1", + "@sap/cds": "^9", + "@sap/cds-common-content": "^1.4.0" + }, + "cds": { + "requires": { + "com.sap.cds/sap-document-ai": { + "model": "com.sap.cds/sap-document-ai" + }, + "com.sap.cds/cds-feature-attachments": { + "model": "com.sap.cds/cds-feature-attachments" + } + } + } +} diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml new file mode 100644 index 0000000..10a7823 --- /dev/null +++ b/samples/bookshop/pom.xml @@ -0,0 +1,189 @@ + + + 4.0.0 + + customer + bookshop-parent + ${revision} + pom + + bookshop parent + + + srv + + + + + 1.0.0-SNAPSHOT + + + 17 + 4.9.0 + 3.5.6 + 0.0.1-alpha + 0.0.1-alpha + 1.5.0 + + + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + com.sap.cds + cds-starter-ai + ${cds-starter-ai.version} + + + + com.sap.cds + cds-feature-recommendations + ${cds-starter-ai.version} + + + + com.sap.cds + cds-feature-sap-document-ai + ${cds-feature-sap-document-ai.version} + + + + com.sap.cds + cds-feature-attachments + ${cds-feature-attachments.version} + + + + + + + + + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + + + + + + maven-compiler-plugin + 3.14.1 + + ${java.version} + UTF-8 + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + true + + + + + + maven-surefire-plugin + 3.5.4 + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.3 + + true + resolveCiFriendliesOnly + + + + flatten + + flatten + + process-resources + + + flatten.clean + + clean + + clean + + + + + + + maven-enforcer-plugin + 3.6.2 + + + Project Structure Checks + + enforce + + + + + 3.6.3 + + + ${java.version} + + + + true + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.4.0 + + + + + + + + + pom.xml + + + + + + + + diff --git a/samples/bookshop/srv/admin-service.cds b/samples/bookshop/srv/admin-service.cds new file mode 100644 index 0000000..91d3de5 --- /dev/null +++ b/samples/bookshop/srv/admin-service.cds @@ -0,0 +1,9 @@ +using {sap.capire.bookshop as my} from '../db/schema'; +service AdminService @(requires: 'any') { + entity Books as projection on my.Books; + entity Authors as projection on my.Authors; +} + +annotate AdminService.Books with { + genre @Common.Text: genre.name; +} diff --git a/samples/bookshop/srv/ai-core-service.cds b/samples/bookshop/srv/ai-core-service.cds new file mode 100644 index 0000000..1fc24c8 --- /dev/null +++ b/samples/bookshop/srv/ai-core-service.cds @@ -0,0 +1,30 @@ +using { AICore } from '@cap-js/ai/srv/AICoreService'; + +service AICoreShowcaseService @(requires: 'any') { + + // Expose AI Core entities as projections for direct browsing + entity ResourceGroups as projection on AICore.resourceGroups; + entity Deployments as projection on AICore.deployments; + entity Configurations as projection on AICore.configurations; + + // Resource Group Management + action setupTenantResources(tenantId : String) returns String; + function getMyResourceGroup() returns String; + + // Deployment Lifecycle + action provisionRpt1(resourceGroupId : String) returns String; + action stopDeployment(deploymentId : String, resourceGroupId : String); + + // Configuration Management + action createConfiguration( + name : String, + scenarioId : String, + executableId : String, + resourceGroupId : String + ) returns String; + + // AI Predictions + action predictCategory(products : array of { + ID : String; name : String; price : String + }) returns array of { ID : String; category : String }; +} diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds new file mode 100644 index 0000000..a6c2ad4 --- /dev/null +++ b/samples/bookshop/srv/attachments.cds @@ -0,0 +1,6 @@ +using {sap.capire.bookshop as my} from '../db/schema'; +using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; + +extend my.SupplierInvoices with { + attachments : Composition of many Attachments; +} diff --git a/samples/bookshop/srv/cat-service.cds b/samples/bookshop/srv/cat-service.cds new file mode 100644 index 0000000..dee7817 --- /dev/null +++ b/samples/bookshop/srv/cat-service.cds @@ -0,0 +1,34 @@ +using {sap.capire.bookshop as my} from '../db/schema'; + +service CatalogService @(requires: 'any'){ + + /** For displaying lists of Books */ + @readonly + entity ListOfBooks as + projection on Books + excluding { + descr + }; + + /** For display in details pages */ + @readonly + entity Books as + projection on my.Books { + *, + author.name as author + } + excluding { + createdBy, + modifiedBy + }; + + action submitOrder(book : Books:ID, quantity : Integer) returns { + stock : Integer + }; + + event OrderedBook : { + book : Books:ID; + quantity : Integer; + buyer : String + }; +} diff --git a/samples/bookshop/srv/pom.xml b/samples/bookshop/srv/pom.xml new file mode 100644 index 0000000..a360e21 --- /dev/null +++ b/samples/bookshop/srv/pom.xml @@ -0,0 +1,183 @@ + + + 4.0.0 + + + customer + bookshop-parent + ${revision} + + + bookshop + jar + + bookshop + + + + + + com.sap.cds + cds-starter-spring-boot + + + + org.springframework.boot + spring-boot-devtools + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.sap.cds + cds-adapter-odata-v4 + runtime + + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-security + + + + com.sap.cds + cds-starter-ai + + + + com.sap.cds + cds-feature-recommendations + + + + com.sap.cds + cds-feature-sap-document-ai + + + + com.sap.cds + cds-feature-attachments + + + + org.springframework.security + spring-security-test + test + + + + com.sap.cds + cds-starter-cloudfoundry + runtime + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + ${project.artifactId} + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + false + + + + repackage + + repackage + + + exec + + + + + + + + com.sap.cds + cds-maven-plugin + + + cds.clean + + clean + + + + + cds.install-node + + install-node + + + + + cds.npm-ci + + npm + + + ci + + + + + cds.resolve + + resolve + + + + + cds.build + + cds + + + + build --for java + deploy --to h2 --with-mocks --dry --out + "${project.basedir}/src/main/resources/schema-h2.sql" + + + + + + cds.generate + + generate + + + cds.gen + true + true + true + true + + + + + + + + diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/Application.java b/samples/bookshop/srv/src/main/java/customer/bookshop/Application.java new file mode 100644 index 0000000..6e93c1c --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/Application.java @@ -0,0 +1,12 @@ +package customer.bookshop; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java new file mode 100644 index 0000000..5e5c7f0 --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -0,0 +1,196 @@ +package customer.bookshop.handlers; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.feature.recommendation.api.RptInferenceClient; +import com.sap.cds.feature.recommendation.api.RptModelSpec; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +@ServiceName("AICoreShowcaseService") +public class AICoreShowcaseHandler implements EventHandler { + + @Autowired private CdsRuntime runtime; + + private RemoteService getAICoreService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } + + // This handler is NOT required - the plugin automatically delegates reads on projections + // of AICore entities. It is kept here only to demonstrate how to query the AICore service + // programmatically, e.g. for custom filtering or post-processing. + @On(event = CqnService.EVENT_READ, entity = "AICoreShowcaseService.Configurations") + public void onReadConfigurations(CdsReadEventContext context) { + context.setResult(getAICoreService().run(Select.from(Configurations_.CDS_NAME))); + } + + @On(event = "setupTenantResources") + public void onSetupTenantResources(EventContext context) { + RemoteService service = getAICoreService(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rgId = rgCtx.getResult(); + context.put("result", rgId); + context.setCompleted(); + } + + @On(event = "getMyResourceGroup") + public void onGetMyResourceGroup(EventContext context) { + RemoteService service = getAICoreService(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rgId = rgCtx.getResult(); + context.put("result", rgId); + context.setCompleted(); + } + + @On(event = "provisionRpt1") + public void onProvisionRpt1(EventContext context) { + String resourceGroupId = (String) context.get("resourceGroupId"); + RemoteService service = getAICoreService(); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroupId); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + context.put("result", deploymentId); + context.setCompleted(); + } + + @On(event = "stopDeployment") + public void onStopDeployment(EventContext context) { + String deploymentId = (String) context.get("deploymentId"); + String resourceGroupId = (String) context.get("resourceGroupId"); + + getAICoreService() + .run( + Update.entity(Deployments_.CDS_NAME) + .where(d -> d.get("id").eq(deploymentId)) + .data( + Map.of( + "targetStatus", + "STOPPED", + "resourceGroup_resourceGroupId", + resourceGroupId))); + context.setCompleted(); + } + + @On(event = "createConfiguration") + public void onCreateConfiguration(EventContext context) { + String name = (String) context.get("name"); + String scenarioId = (String) context.get("scenarioId"); + String executableId = (String) context.get("executableId"); + String resourceGroupId = (String) context.get("resourceGroupId"); + + Result result = + getAICoreService() + .run( + Insert.into(Configurations_.CDS_NAME) + .entry( + Map.of( + "name", name, + "scenarioId", scenarioId, + "executableId", executableId, + "resourceGroup_resourceGroupId", resourceGroupId, + "parameterBindings", + List.of( + Map.of("key", "modelName", "value", "sap-rpt-1-small"), + Map.of("key", "modelVersion", "value", "latest"))))); + + String configId = (String) result.single().get("id"); + context.put("result", configId); + context.setCompleted(); + } + + @SuppressWarnings("unchecked") + @On(event = "predictCategory") + public void onPredictCategory(EventContext context) { + List> products = (List>) context.get("products"); + + List contextRows = + List.of( + CdsData.create( + Map.of( + "ID", "ctx-1", "name", "Laptop", "price", "999.99", "category", "Electronics")), + CdsData.create( + Map.of( + "ID", "ctx-2", "name", "Mouse", "price", "29.99", "category", "Electronics")), + CdsData.create( + Map.of("ID", "ctx-3", "name", "Shirt", "price", "49.99", "category", "Clothing")), + CdsData.create( + Map.of("ID", "ctx-4", "name", "Novel", "price", "14.99", "category", "Books")), + CdsData.create( + Map.of( + "ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); + + RemoteService service = getAICoreService(); + RptInferenceClient client = createRptClient(service, List.of("ID")); + + List> results = new ArrayList<>(); + for (Map product : products) { + CdsData predictionRow = CdsData.create(new HashMap<>(product)); + List predictions = client.predict(predictionRow, contextRows, List.of("category")); + for (CdsData prediction : predictions) { + String id = (String) prediction.get("ID"); + Object categoryObj = prediction.get("category"); + String category = + categoryObj instanceof List list && !list.isEmpty() + ? extractPrediction(list) + : String.valueOf(categoryObj); + results.add(Map.of("ID", id, "category", category)); + } + } + + context.put("result", results); + context.setCompleted(); + } + + private String extractPrediction(List predictionList) { + if (predictionList.get(0) instanceof Map map) { + Object prediction = map.get("prediction"); + return prediction != null ? prediction.toString() : ""; + } + return predictionList.get(0).toString(); + } + + /** Helper to resolve a ready-to-use RptInferenceClient from the AI Core RemoteService. */ + private static RptInferenceClient createRptClient(RemoteService service, List keyNames) { + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(rg); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + + InferenceClientContext infCtx = InferenceClientContext.create(); + infCtx.setResourceGroupId(rg); + infCtx.setDeploymentId(depCtx.getResult()); + service.emit(infCtx); + + return new RptInferenceClient(infCtx.getResult(), keyNames); + } +} diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java new file mode 100644 index 0000000..9bb8f7b --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java @@ -0,0 +1,64 @@ +package customer.bookshop.handlers; + +import cds.gen.catalogservice.Books; +import cds.gen.catalogservice.Books_; +import cds.gen.catalogservice.CatalogService_; +import cds.gen.catalogservice.OrderedBook; +import cds.gen.catalogservice.OrderedBookContext; +import cds.gen.catalogservice.SubmitOrderContext; +import cds.gen.catalogservice.SubmitOrderContext.ReturnType; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.persistence.PersistenceService; +import java.util.stream.Stream; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +@ServiceName(CatalogService_.CDS_NAME) +public class CatalogServiceHandler implements EventHandler { + + @Autowired private PersistenceService db; + + @On + public void submitOrder(SubmitOrderContext context) { + // decrease and update stock in database + db.run( + Update.entity(Books_.class) + .byId(context.getBook()) + .set(b -> b.stock(), s -> s.minus(context.getQuantity()))); + + // read new stock from database + Books book = + db.run(Select.from(Books_.class).where(b -> b.ID().eq(context.getBook()))) + .single(Books.class); + + // return new stock to client + ReturnType result = SubmitOrderContext.ReturnType.create(); + result.setStock(book.getStock()); + + OrderedBook orderedBook = OrderedBook.create(); + orderedBook.setBook(book.getId()); + orderedBook.setQuantity(context.getQuantity()); + orderedBook.setBuyer(context.getUserInfo().getName()); + + OrderedBookContext orderedBookEvent = OrderedBookContext.create(); + orderedBookEvent.setData(orderedBook); + context.getService().emit(orderedBookEvent); + + context.setResult(result); + } + + @After(event = CqnService.EVENT_READ) + public void discountBooks(Stream books) { + books + .filter(b -> b.getTitle() != null && b.getStock() != null) + .filter(b -> b.getStock() > 200) + .forEach(b -> b.setTitle(b.getTitle() + " (discounted)")); + } +} diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java new file mode 100644 index 0000000..3a87cf2 --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java @@ -0,0 +1,117 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package customer.bookshop.handlers; + +import cds.gen.sap.capire.bookshop.SupplierInvoices; +import cds.gen.sap.capire.bookshop.SupplierInvoices_; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; +import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.persistence.PersistenceService; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Handles extraction results from the SAP Document AI plugin. Parses the extraction JSON returned + * by the DIE service and populates the corresponding SupplierInvoice entity with the extracted + * header fields (invoice number, date, total amount, currency). + */ +@Component +@ServiceName(value = "*", type = ApplicationService.class) +public class DocumentExtractionResultHandler implements EventHandler { + + private static final Logger logger = + LoggerFactory.getLogger(DocumentExtractionResultHandler.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String STATUS_EXTRACTED = "EXTRACTED"; + private static final String STATUS_EXTRACTING = "EXTRACTING"; + private static final String STATUS_FAILED = "FAILED"; + + @Autowired private PersistenceService db; + + @On(event = DocumentExtractionResultContext.CDS_NAME) + public void onExtractionCompleted(DocumentExtractionResultContext context) { + DocumentExtractionResult data = context.getData(); + logger.info("[bookshop] Invoice extraction completed! jobId={}", data.getJobId()); + + try { + JsonNode root = objectMapper.readTree(data.getExtractionResult()); + JsonNode headerFields = root.path("extraction").path("headerFields"); + + // Extract relevant header fields from the DIE response + String documentNumber = getHeaderFieldValue(headerFields, "documentNumber"); + String documentDate = getHeaderFieldValue(headerFields, "documentDate"); + String currencyCode = getHeaderFieldValue(headerFields, "currencyCode"); + BigDecimal grossAmount = getHeaderFieldNumber(headerFields, "grossAmount"); + + logger.info( + "[bookshop] Extracted: number={}, date={}, amount={} {}", + documentNumber, + documentDate, + grossAmount, + currencyCode); + + // Update the invoice that is currently in EXTRACTING status. + // In a production app you would correlate by a stored jobId; for this sample + // we use the status as a simple correlation mechanism. + Map updateData = new HashMap<>(); + updateData.put(SupplierInvoices.STATUS_CODE, STATUS_EXTRACTED); + if (documentNumber != null) updateData.put(SupplierInvoices.INVOICE_NUMBER, documentNumber); + if (documentDate != null) updateData.put(SupplierInvoices.INVOICE_DATE, documentDate); + if (grossAmount != null) updateData.put(SupplierInvoices.TOTAL_AMOUNT, grossAmount); + if (currencyCode != null) updateData.put(SupplierInvoices.CURRENCY_CODE, currencyCode); + + long updated = + db.run( + Update.entity(SupplierInvoices_.CDS_NAME) + .where(i -> i.get(SupplierInvoices.STATUS_CODE).eq(STATUS_EXTRACTING)) + .data(updateData)) + .rowCount(); + + logger.info("[bookshop] Updated {} invoice(s) with extracted data", updated); + + } catch (Exception e) { + logger.error("[bookshop] Failed to process extraction result", e); + db.run( + Update.entity(SupplierInvoices_.CDS_NAME) + .where(i -> i.get(SupplierInvoices.STATUS_CODE).eq(STATUS_EXTRACTING)) + .data(Map.of(SupplierInvoices.STATUS_CODE, STATUS_FAILED))); + } + + context.setCompleted(); + } + + private String getHeaderFieldValue(JsonNode headerFields, String fieldName) { + for (JsonNode field : headerFields) { + if (fieldName.equals(field.path("name").asText())) { + return field.path("value").asText(null); + } + } + return null; + } + + private BigDecimal getHeaderFieldNumber(JsonNode headerFields, String fieldName) { + for (JsonNode field : headerFields) { + if (fieldName.equals(field.path("name").asText())) { + JsonNode value = field.path("value"); + if (value.isNumber()) { + return BigDecimal.valueOf(value.doubleValue()); + } + return null; + } + } + return null; + } +} diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/SupplierInvoiceHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/SupplierInvoiceHandler.java new file mode 100644 index 0000000..fd31624 --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/SupplierInvoiceHandler.java @@ -0,0 +1,126 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package customer.bookshop.handlers; + +import cds.gen.sap.capire.bookshop.SupplierInvoices; +import cds.gen.sap.capire.bookshop.SupplierInvoicesAttachments; +import cds.gen.sap.capire.bookshop.SupplierInvoicesAttachments_; +import cds.gen.sap.capire.bookshop.SupplierInvoices_; +import cds.gen.supplierinvoicesservice.SupplierInvoicesExtractInvoiceDataContext; +import cds.gen.supplierinvoicesservice.SupplierInvoicesService_; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.Service; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.persistence.PersistenceService; +import java.io.InputStream; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +@ServiceName(SupplierInvoicesService_.CDS_NAME) +public class SupplierInvoiceHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(SupplierInvoiceHandler.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String STATUS_EXTRACTING = "EXTRACTING"; + + @Autowired private PersistenceService db; + @Autowired private CdsModel cdsModel; + @Autowired private ServiceCatalog serviceCatalog; + + @On(event = SupplierInvoicesExtractInvoiceDataContext.CDS_NAME) + public void onExtractInvoiceData(SupplierInvoicesExtractInvoiceDataContext context) { + String invoiceId = + (String) + CqnAnalyzer.create(cdsModel) + .analyze(context.getCqn()) + .rootKeys() + .get(SupplierInvoices.ID); + + if (invoiceId == null) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Could not determine invoice ID."); + } + + // Read the first attachment for this invoice + var attachment = + db.run( + Select.from(SupplierInvoicesAttachments_.class) + .columns(a -> a.ID(), a -> a.fileName(), a -> a.mimeType(), a -> a.content()) + .where(a -> a.up__ID().eq(invoiceId)) + .limit(1)) + .first(SupplierInvoicesAttachments.class) + .orElse(null); + + if (attachment == null) { + throw new ServiceException( + ErrorStatuses.BAD_REQUEST, + "No attachment found for this invoice. Upload a PDF document first."); + } + + String fileName = attachment.getFileName(); + String mimeType = + attachment.getMimeType() != null ? attachment.getMimeType() : "application/pdf"; + InputStream content = attachment.getContent(); + + if (content == null) { + throw new ServiceException( + ErrorStatuses.BAD_REQUEST, "Attachment has no content. Please re-upload the document."); + } + + // Emit DocumentExtraction event to the doc-ai plugin + Service documentAiService = + serviceCatalog.getService(Service.class, DocumentAiService_.CDS_NAME); + if (documentAiService == null) { + throw new ServiceException( + ErrorStatuses.SERVER_ERROR, + "Document AI service is not available. Ensure cds-feature-sap-document-ai is configured."); + } + + DocumentExtraction event = DocumentExtraction.create(); + event.setFileName(fileName); + event.setMimeType(mimeType); + event.setContent(content); + try { + event.setOptions( + objectMapper.writeValueAsString( + Map.of( + "clientId", "default", + "documentType", "invoice", + "schemaId", "cf8cc8a9-1eee-42d9-9a3e-507a61baac23", + "templateId", "detect"))); + } catch (JsonProcessingException e) { + throw new ServiceException( + ErrorStatuses.SERVER_ERROR, "Failed to build extraction options", e); + } + + DocumentExtractionContext eventContext = DocumentExtractionContext.create(); + eventContext.setData(event); + documentAiService.emit(eventContext); + + // Update invoice status to EXTRACTING + db.run( + Update.entity(SupplierInvoices_.CDS_NAME) + .where(i -> i.get(SupplierInvoices.ID).eq(invoiceId)) + .data(Map.of(SupplierInvoices.STATUS_CODE, STATUS_EXTRACTING))); + + logger.info("[SupplierInvoiceHandler] Emitted DocumentExtraction for invoiceId={}", invoiceId); + context.setResult(true); + } +} diff --git a/samples/bookshop/srv/src/main/resources/application.yaml b/samples/bookshop/srv/src/main/resources/application.yaml new file mode 100644 index 0000000..6fe72b2 --- /dev/null +++ b/samples/bookshop/srv/src/main/resources/application.yaml @@ -0,0 +1,59 @@ +logging: + level: + root: INFO + com.sap.cds.feature.aicore.core: DEBUG + com.sap.cds.feature.documentai: DEBUG +--- +spring: + datasource: + url: "jdbc:h2:mem:testdb" + config: + activate: + on-profile: default + sql: + init: + platform: h2 +cds: + requires: + AICore: + resourceGroup: default + security: + mock: + users: + admin: + password: admin + roles: + - admin + user: + password: user + data-source: + auto-config: + enabled: false + outbox: + services: + DefaultOutboxUnordered: + maxAttempts: 10 + persistent: + scheduler: + enabled: true + document-ai: + polling: + interval-seconds: 5 +--- +management: + endpoint: + health: + show-components: always + probes: + enabled: true + endpoints: + web: + exposure: + include: health + health: + defaults: + enabled: false + ping: + enabled: true + db: + enabled: true diff --git a/samples/bookshop/srv/src/test/java/customer/bookshop/ApplicationTest.java b/samples/bookshop/srv/src/test/java/customer/bookshop/ApplicationTest.java new file mode 100644 index 0000000..9672588 --- /dev/null +++ b/samples/bookshop/srv/src/test/java/customer/bookshop/ApplicationTest.java @@ -0,0 +1,21 @@ +package customer.bookshop; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +@SpringBootTest +@AutoConfigureMockMvc +class ApplicationTest { + + @Autowired private ApplicationContext context; + + @Test + void checkApplicationContextCanBeLoaded() { + assertThat(context).isNotNull(); + } +} diff --git a/samples/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java b/samples/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java new file mode 100644 index 0000000..2bd34ed --- /dev/null +++ b/samples/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java @@ -0,0 +1,39 @@ +package customer.bookshop.handlers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import cds.gen.catalogservice.Books; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CatalogServiceHandlerTest { + + private CatalogServiceHandler handler = new CatalogServiceHandler(); + private Books book = Books.create(); + + @BeforeEach + public void prepareBook() { + book.setTitle("title"); + } + + @Test + void testDiscount() { + book.setStock(500); + handler.discountBooks(Stream.of(book)); + assertEquals("title (discounted)", book.getTitle()); + } + + @Test + void testNoDiscount() { + book.setStock(100); + handler.discountBooks(Stream.of(book)); + assertEquals("title", book.getTitle()); + } + + @Test + void testNoStockAvailable() { + handler.discountBooks(Stream.of(book)); + assertEquals("title", book.getTitle()); + } +} diff --git a/samples/bookshop/srv/supplier-invoices-service.cds b/samples/bookshop/srv/supplier-invoices-service.cds new file mode 100644 index 0000000..4c736ca --- /dev/null +++ b/samples/bookshop/srv/supplier-invoices-service.cds @@ -0,0 +1,11 @@ +using {sap.capire.bookshop as my} from '../db/schema'; + +service SupplierInvoicesService @(requires: 'any') { + @odata.draft.enabled + entity SupplierInvoices as projection on my.SupplierInvoices actions { + action extractInvoiceData() returns Boolean; + }; + + @readonly entity Suppliers as projection on my.Suppliers; + @readonly entity InvoiceStatus as projection on my.InvoiceStatus; +} diff --git a/samples/bookshop/xs-security.json b/samples/bookshop/xs-security.json new file mode 100644 index 0000000..9d8a299 --- /dev/null +++ b/samples/bookshop/xs-security.json @@ -0,0 +1,10 @@ +{ + "scopes": [], + "attributes": [], + "role-templates": [], + "oauth2-configuration": { + "redirect-uris": [ + "https://*.cfapps.eu12.hana.ondemand.com/**" + ] + } +}