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..a37d83c --- /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-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..923d44d --- /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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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..251b68a --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,149 @@ +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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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 + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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..6857eb6 --- /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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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..0dc5f95 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,81 @@ +[![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 | + +### 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" +} +``` + +## 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 + +In [`samples/bookshop`](samples/bookshop) you can find a complete CAP Java bookshop demonstrating both plugins: + +```bash +mvn clean install +cd samples/bookshop +mvn spring-boot:run +``` + +## Local Development + +```bash +mvn clean install # build all modules +mvn test # run unit tests +``` + +For integration tests against a real AI Core instance: + +```bash +cds bind ai-core -2 +cds bind --exec mvn test -pl integration-tests/spring -am +``` + +## 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..1b1125f --- /dev/null +++ b/cds-feature-ai-core/README.md @@ -0,0 +1,109 @@ +# 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) - detected automatically via `ServiceBindingUtils` +- **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) 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..c49b701 --- /dev/null +++ b/cds-feature-ai-core/pom.xml @@ -0,0 +1,122 @@ + + + 4.0.0 + + + com.sap.cds + cds-starter-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 + 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/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/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java new file mode 100644 index 0000000..40374bb --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -0,0 +1,77 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.cds.CqnService; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; + +/** + * CAP service contract for SAP AI Core integration. + * + *

The service exposes resource-group, configuration and deployment lifecycle as CDS entities + * (see {@link #RESOURCE_GROUPS}, {@link #DEPLOYMENTS}, {@link #CONFIGURATIONS}) and additionally + * provides programmatic helpers to: + * + *

+ * + *

The implementation is tenant-aware: it reads the current tenant from the {@code + * RequestContext}. Callers do not need to pass tenant identifiers explicitly. + */ +public interface AICoreService extends CqnService { + + /** Default service name under which an instance is registered in the service catalog. */ + String DEFAULT_NAME = "AICore"; + + /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ + String RESOURCE_GROUPS = "AICore.resourceGroups"; + + /** Qualified name of the {@code deployments} entity exposed by this service. */ + String DEPLOYMENTS = "AICore.deployments"; + + /** Qualified name of the {@code configurations} entity exposed by this service. */ + String CONFIGURATIONS = "AICore.configurations"; + + /** + * Returns the AI Core resource group ID associated with the current tenant. + * + *

When multi-tenancy is disabled the configured default resource group is returned. When + * enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and + * created on first call if it does not exist. + * + * @return the AI Core resource group ID for the current tenant + */ + String resourceGroup(); + + /** + * Returns the deployment ID for the given model spec inside the given resource group. + * + *

Looks up an existing RUNNING/PENDING deployment that matches the spec, otherwise creates a + * configuration (if missing) and a new deployment, then polls until the deployment reaches + * RUNNING. Results are cached per {@code (resourceGroupId, configurationName)} pair. + * + * @param resourceGroupId the AI Core resource group to operate in + * @param spec the deployment specification (scenario, executable, configuration name and + * existing-match predicate) + * @return the deployment ID + */ + String deploymentId(String resourceGroupId, ModelDeploymentSpec spec); + + /** + * Returns an {@link ApiClient} preconfigured with the inference destination for the given + * deployment, suitable for constructing foundation-model SDK clients. + * + * @param resourceGroupId the AI Core resource group containing the deployment + * @param deploymentId the deployment ID returned by {@link #deploymentId(String, + * ModelDeploymentSpec)} + * @return a configured {@link ApiClient} pointing at the deployment's inference endpoint + */ + ApiClient inferenceClient(String resourceGroupId, String deploymentId); +} 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..a51d1b6 --- /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 {@link AICoreService#deploymentId(String, + * ModelDeploymentSpec)} 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/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/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java new file mode 100644 index 0000000..bfd4c8c --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -0,0 +1,126 @@ +/* + * © 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.api.AICoreService; +import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; +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.MockEntityHandler; +import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; +import com.sap.cds.services.environment.CdsProperties; +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} service and its event handlers into + * the CAP Java runtime. + * + *

Detects the presence of an SAP AI Core service binding (either a regular service binding or + * the {@code AICORE_SERVICE_KEY} environment variable used for hybrid local testing) and registers + * either {@link AICoreServiceImpl} (when a binding is found) or {@link MockAICoreServiceImpl} + * (no-binding fallback). Picked up automatically through {@code ServiceLoader}; applications do not + * need to instantiate this class directly. + */ +public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class); + + private static boolean hasAICoreBinding(CdsRuntime runtime) { + boolean hasServiceBinding = + runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + if (hasServiceBinding) { + return true; + } + String envKey = System.getenv("AICORE_SERVICE_KEY"); + return envKey != null && !envKey.isBlank(); + } + + /** + * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url} + * property or the presence of a {@link DeploymentService} in the service catalog. This aligns + * with the standard CAP Java convention — no custom property flag is needed. + */ + 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; + } + + @Override + public void services(CdsRuntimeConfigurer configurer) { + CdsRuntime runtime = configurer.getCdsRuntime(); + + boolean hasBinding = hasAICoreBinding(runtime); + + boolean multiTenancyEnabled = detectMultiTenancy(runtime); + + if (hasBinding) { + AICoreServiceImpl service = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + runtime, + multiTenancyEnabled, + new DeploymentApi(), + new ConfigurationApi(), + new ResourceGroupApi(), + new AiCoreService()); + configurer.service(service); + logger.info("Registered AICoreService backed by AI Core binding."); + } else { + MockAICoreServiceImpl mockService = + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + configurer.service(mockService); + logger.info("Registered MockAICoreService (no AI Core binding found)."); + } + } + + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + CdsRuntime runtime = configurer.getCdsRuntime(); + + AICoreService registered = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + if (registered instanceof AICoreServiceImpl service) { + configurer.eventHandler(new ResourceGroupHandler(service)); + configurer.eventHandler(new DeploymentHandler(service)); + configurer.eventHandler(new ConfigurationHandler(service)); + configurer.eventHandler(new ActionHandler(service)); + configurer.eventHandler(new AICoreApplicationServiceHandler(service)); + logger.debug("Registered Prod AI-Core Implementation"); + + if (service.isMultiTenancyEnabled()) { + configurer.eventHandler(new AICoreSetupHandler(service)); + logger.debug("Registered AI-Core Setup Handler for MTX subscribe/unsubscribe."); + } + } else if (registered instanceof MockAICoreServiceImpl mockService) { + configurer.eventHandler(new MockEntityHandler()); + configurer.eventHandler(new AICoreApplicationServiceHandler(mockService)); + if (mockService.isMultiTenancyEnabled()) { + configurer.eventHandler(new MockAICoreSetupHandler(mockService)); + logger.debug("Registered Mock AI-Core Setup Handler for MTX subscribe/unsubscribe."); + } + logger.debug("Registered Mock AI-Core Implementation"); + } + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java new file mode 100644 index 0000000..52eeb4f --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -0,0 +1,426 @@ +/* + * © 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.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.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.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.AICoreService; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.runtime.CdsRuntime; +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.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Production implementation of {@link AICoreService} backed by an SAP AI Core service binding. + * + *

Provides resource-group, configuration and deployment lifecycle management together with a + * factory for inference {@link ApiClient}s scoped to a specific deployment. Resource group lookup + * results, deployment IDs and per-cache-key locks are cached in bounded {@link Caffeine} caches so + * repeated calls within a tenant or resource group avoid round-trips to AI Core. + * + *

Most state-changing AI Core calls are wrapped in a Resilience4j {@link Retry} that retries + * known transient errors (HTTP 403/404/412, see {@link #notReadyYet(OpenApiRequestException)}) with + * exponential backoff capped at 30 seconds. + */ +public class AICoreServiceImpl extends AbstractAICoreService { + + private static final Logger logger = LoggerFactory.getLogger(AICoreServiceImpl.class); + + 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; + private static final Duration DEFAULT_CACHE_EXPIRY = Duration.ofHours(1); + private static final int DEFAULT_CACHE_MAX_SIZE = 10_000; + + private final Cache tenantResourceGroupCache; + private final Cache resourceGroupDeploymentCache; + + /** + * 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 int maxRetries; + private final long initialDelayMs; + private final String defaultResourceGroup; + private final String resourceGroupPrefix; + private final boolean multiTenancyEnabled; + private final Retry retry; + private final DeploymentApi deploymentApi; + private final ConfigurationApi configurationApi; + private final ResourceGroupApi resourceGroupApi; + private final AiCoreService sdkService; + + public AICoreServiceImpl( + String name, + CdsRuntime runtime, + boolean multiTenancyEnabled, + DeploymentApi deploymentApi, + ConfigurationApi configurationApi, + ResourceGroupApi resourceGroupApi, + AiCoreService sdkService) { + super(name, runtime); + this.multiTenancyEnabled = multiTenancyEnabled; + CdsEnvironment env = runtime.getEnvironment(); + this.maxRetries = + env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); + this.initialDelayMs = + env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); + this.defaultResourceGroup = + env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); + this.resourceGroupPrefix = + env.getProperty( + "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); + this.retry = buildRetry(maxRetries, initialDelayMs); + this.tenantResourceGroupCache = newCache(); + this.resourceGroupDeploymentCache = newCache(); + this.deploymentApi = deploymentApi; + this.configurationApi = configurationApi; + this.resourceGroupApi = resourceGroupApi; + this.sdkService = sdkService; + } + + private static Cache newCache() { + return Caffeine.newBuilder() + .maximumSize(DEFAULT_CACHE_MAX_SIZE) + .expireAfterAccess(DEFAULT_CACHE_EXPIRY) + .build(); + } + + @Override + public String resourceGroupForTenant(String tenantId) { + if (!multiTenancyEnabled || tenantId == null) { + logger.debug("Using default resource group {}", defaultResourceGroup); + return defaultResourceGroup; + } + return getOrCreateResourceGroupForTenant(tenantId); + } + + @Override + public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { + String cacheKey = deploymentCacheKey(resourceGroupId, spec); + Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object()); + synchronized (lock) { + String cached = resourceGroupDeploymentCache.getIfPresent(cacheKey); + if (cached != null) { + try { + var current = deploymentApi.get(resourceGroupId, cached); + if (AiDeploymentStatus.RUNNING.equals(current.getStatus()) + || AiDeploymentStatus.PENDING.equals(current.getStatus())) { + return cached; + } + } catch (OpenApiRequestException e) { + // Only 404 means the cached deployment was deleted out-of-band — drop the stale entry + // and fall through to discover or create a new one. Any other status (5xx, 401, 412, + // network errors, …) is propagated so the caller's retry/backoff policy can handle it + // rather than silently invalidating a potentially valid cache entry and triggering a + // duplicate deployment. + Integer status = e.statusCode(); + if (status == null || status != 404) { + throw e; + } + logger.debug( + "Cached deployment {} in resource group {} no longer exists (404), " + + "invalidating cache entry", + cached, + resourceGroupId); + } + resourceGroupDeploymentCache.invalidate(cacheKey); + } + 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()) { + String deploymentId = existing.get(); + resourceGroupDeploymentCache.put(cacheKey, deploymentId); + return deploymentId; + } + return createDeployment(resourceGroupId, spec, cacheKey); + } + } + + @Override + public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { + var destination = + sdkService.getInferenceDestination(resourceGroupId).usingDeploymentId(deploymentId); + logger.debug("Inference destination URI: {}", destination.getUri()); + return ApiClient.create(destination); + } + + @Override + public boolean isMultiTenancyEnabled() { + return multiTenancyEnabled; + } + + @Override + public Retry getRetry() { + return retry; + } + + @Override + public String getDefaultResourceGroup() { + return defaultResourceGroup; + } + + @Override + public String getResourceGroupPrefix() { + return resourceGroupPrefix; + } + + @Override + public Map getTenantResourceGroupCache() { + return tenantResourceGroupCache.asMap(); + } + + @Override + public Map getResourceGroupDeploymentCache() { + return resourceGroupDeploymentCache.asMap(); + } + + public DeploymentApi getDeploymentApi() { + return deploymentApi; + } + + public ConfigurationApi getConfigurationApi() { + return configurationApi; + } + + public ResourceGroupApi getResourceGroupApi() { + return resourceGroupApi; + } + + @Override + public String resolveResourceGroupFromKeys(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 resourceGroup(); + } + + @Override + public void clearTenantCache(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); + if (resourceGroupId != null) { + String prefix = resourceGroupId + "::"; + resourceGroupDeploymentCache + .asMap() + .keySet() + .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } + + /** + * Builds the cache key for the {@code resourceGroupDeploymentCache} and {@code deploymentLocks} + * maps. Package-private so tests can derive the same key the production code uses, instead of + * duplicating the format inline. + */ + static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { + return resourceGroupId + "::" + spec.configurationName(); + } + + private String getOrCreateResourceGroupForTenant(String tenantId) { + return tenantResourceGroupCache.get( + tenantId, + key -> { + List labelSelector = List.of(TENANT_LABEL_KEY + "=" + key); + 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 = resourceGroupPrefix + key; + BckndResourceGroupLabel label = + BckndResourceGroupLabel.create().key(TENANT_LABEL_KEY).value(key); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create() + .resourceGroupId(resourceGroupId) + .labels(List.of(label)); + try { + resourceGroupApi.create(request); + logger.debug("Created resource group {} for tenant {}", resourceGroupId, key); + } 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; + }); + } + + private String createDeployment( + String resourceGroupId, ModelDeploymentSpec spec, String cacheKey) { + AiConfigurationList configList = + configurationApi.query( + resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); + String configId = + 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)); + + return Retry.decorateSupplier( + retry, + () -> { + var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); + var deployResponse = deploymentApi.create(resourceGroupId, deployRequest); + String deploymentId = deployResponse.getId(); + logger.debug( + "Created deployment {} ({}) in resource group {}, polling for RUNNING", + deploymentId, + spec.configurationName(), + resourceGroupId); + return pollUntilRunning(resourceGroupId, deploymentId, cacheKey); + }) + .get(); + } + + 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 = 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, String cacheKey) { + Retry pollRetry = + Retry.of( + "pollDeployment", + RetryConfig.custom() + .maxAttempts(maxRetries) + .intervalFunction(IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0)) + .retryOnResult( + deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus())) + .retryOnException(e -> false) + .build()); + + AiDeploymentResponseWithDetails result = + Retry.decorateSupplier( + pollRetry, + () -> { + var current = deploymentApi.get(resourceGroupId, deploymentId); + logger.debug("Deployment {} status: {}", deploymentId, current.getStatus()); + return current; + }) + .get(); + + if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) { + resourceGroupDeploymentCache.put(cacheKey, deploymentId); + return deploymentId; + } + logger.error( + "Deployment {} in resource group {} did not reach RUNNING status after {} retries", + deploymentId, + resourceGroupId, + maxRetries); + throw new ServiceException( + ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available"); + } + + private AiDeploymentList queryDeploymentsUntilReady( + String resourceGroupId, ModelDeploymentSpec spec) { + return Retry.decorateSupplier( + retry, + () -> + deploymentApi.query( + resourceGroupId, null, null, spec.scenarioId(), null, null, null, null)) + .get(); + } + + 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; + } + + private static final long MAX_INTERVAL_MS = 30_000L; + + 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/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java new file mode 100644 index 0000000..e4fd983 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.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 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.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.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 AICoreServiceImpl service; + + public AICoreSetupHandler(AICoreServiceImpl service) { + this.service = service; + } + + @After(event = DeploymentService.EVENT_SUBSCRIBE) + public void afterSubscribe(SubscribeEventContext context) { + String tenantId = context.getTenant(); + logger.debug("Creating AI Core resources for tenant {}", tenantId); + try { + String resourceGroupId = service.resourceGroupForTenant(tenantId); + logger.info("Created 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(event = DeploymentService.EVENT_UNSUBSCRIBE) + 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. + service.clearTenantCache(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 { + service.getResourceGroupApi().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 = service.getTenantResourceGroupCache().get(tenantId); + if (cached != null) { + return cached; + } + logger.debug( + "No cached resource group for tenant {}, falling back to AI Core lookup", tenantId); + ResourceGroupApi api = service.getResourceGroupApi(); + List labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); + BckndResourceGroupList result; + try { + result = api.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/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java new file mode 100644 index 0000000..0e7ec79 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -0,0 +1,94 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.request.UserInfo; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.utils.services.AbstractCqnService; +import io.github.resilience4j.retry.Retry; +import java.util.Map; + +/** + * Abstract base class for AICore service implementations, providing shared internal methods for + * cache access, configuration, and resource group resolution. These methods are not part of the + * public {@link AICoreService} contract but are shared between the real and mock implementations. + */ +public abstract class AbstractAICoreService extends AbstractCqnService implements AICoreService { + + protected AbstractAICoreService(String name, CdsRuntime runtime) { + super(name, runtime); + } + + /** Returns the {@link CdsRuntime} that this service was created with. */ + public CdsRuntime getRuntime() { + return runtime; + } + + /** + * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no + * tenant is set (e.g. in single-tenant mode). + */ + public String currentTenantId() { + return RequestContext.getCurrent(runtime).getUserInfo().getTenant(); + } + + /** + * Returns whether the current request is running as a system/provider user. Provider users are + * allowed to see all tenants' resources. + */ + public boolean isProviderUser() { + UserInfo userInfo = RequestContext.getCurrent(runtime).getUserInfo(); + return userInfo.isSystemUser() || userInfo.isInternalUser(); + } + + /** + * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService} + * interface — callers should not need to be aware of multi-tenancy. + */ + public abstract boolean isMultiTenancyEnabled(); + + /** + * Returns the shared {@link Retry} used internally for transient AI Core errors. Not part of the + * public {@link AICoreService} interface but accessible to internal callers (e.g. the + * recommendations module) that need consistent backoff behaviour. + */ + public abstract Retry getRetry(); + + /** + * Returns the resource group for the given tenant ID. This is an internal method used by setup + * handlers where the tenant ID is explicitly available from the subscribe/unsubscribe context. + * + * @param tenantId the CDS tenant identifier + * @return the AI Core resource group ID + */ + public abstract String resourceGroupForTenant(String tenantId); + + @Override + public String resourceGroup() { + return resourceGroupForTenant(currentTenantId()); + } + + /** Returns the configured default resource group identifier. */ + public abstract String getDefaultResourceGroup(); + + /** Returns the configured resource group prefix used for tenant-specific groups. */ + public abstract String getResourceGroupPrefix(); + + /** Returns the tenant-to-resource-group cache as an unmodifiable view. */ + public abstract Map getTenantResourceGroupCache(); + + /** Returns the resource-group-to-deployment cache as an unmodifiable view. */ + public abstract Map getResourceGroupDeploymentCache(); + + /** Evicts all cache entries associated with the given tenant. */ + public abstract void clearTenantCache(String tenantId); + + /** + * Resolves the resource group ID from CQN keys, checking for explicit resource group references + * before falling back to tenant-based resolution. + */ + public abstract String resolveResourceGroupFromKeys(Map keys); +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java new file mode 100644 index 0000000..aaee071 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java @@ -0,0 +1,116 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MockAICoreServiceImpl extends AbstractAICoreService { + + private static final Logger logger = LoggerFactory.getLogger(MockAICoreServiceImpl.class); + + private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); + private final Map resourceGroupDeploymentCache = new ConcurrentHashMap<>(); + private final Retry retry; + private final String defaultResourceGroup; + private final String resourceGroupPrefix; + private final boolean multiTenancyEnabled; + + public MockAICoreServiceImpl(String name, CdsRuntime runtime) { + this(name, runtime, false); + } + + public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { + super(name, runtime); + logger.info("MockAICoreService initialized - all operations use in-memory storage."); + this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build()); + CdsEnvironment env = runtime.getEnvironment(); + this.defaultResourceGroup = + env.getProperty("cds.ai.core.resourceGroup", String.class, "default"); + this.resourceGroupPrefix = + env.getProperty("cds.ai.core.resourceGroupPrefix", String.class, "cds-"); + this.multiTenancyEnabled = multiTenancyEnabled; + } + + @Override + public String resourceGroupForTenant(String tenantId) { + if (!multiTenancyEnabled) { + return defaultResourceGroup; + } + return tenantResourceGroupCache.computeIfAbsent(tenantId, id -> resourceGroupPrefix + id); + } + + @Override + public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { + String key = resourceGroupId + "::" + spec.configurationName(); + return resourceGroupDeploymentCache.computeIfAbsent(key, k -> "mock-deployment-" + k); + } + + @Override + public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { + throw new UnsupportedOperationException( + "MockAICoreServiceImpl does not provide an inference client; tests should stub inference."); + } + + @Override + public boolean isMultiTenancyEnabled() { + return multiTenancyEnabled; + } + + @Override + public Retry getRetry() { + return retry; + } + + @Override + public String getDefaultResourceGroup() { + return defaultResourceGroup; + } + + @Override + public String getResourceGroupPrefix() { + return resourceGroupPrefix; + } + + @Override + public Map getTenantResourceGroupCache() { + return tenantResourceGroupCache; + } + + @Override + public Map getResourceGroupDeploymentCache() { + return resourceGroupDeploymentCache; + } + + @Override + public void clearTenantCache(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.remove(tenantId); + if (resourceGroupId != null) { + String prefix = resourceGroupId + "::"; + resourceGroupDeploymentCache + .keySet() + .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } + + @Override + public String resolveResourceGroupFromKeys(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 defaultResourceGroup; + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java new file mode 100644 index 0000000..fe152fd --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java @@ -0,0 +1,41 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +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.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 MockAICoreServiceImpl service; + + public MockAICoreSetupHandler(MockAICoreServiceImpl service) { + this.service = service; + } + + @After(event = DeploymentService.EVENT_SUBSCRIBE) + public void afterSubscribe(SubscribeEventContext context) { + String tenantId = context.getTenant(); + String resourceGroupId = service.resourceGroupForTenant(tenantId); + logger.info( + "Mock created in-memory resource group {} for tenant {}", resourceGroupId, tenantId); + } + + @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + public void beforeUnsubscribe(UnsubscribeEventContext context) { + String tenantId = context.getTenant(); + service.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/AICoreApplicationServiceHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java new file mode 100644 index 0000000..1b340cd --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java @@ -0,0 +1,103 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnStructuredTypeRef; +import com.sap.cds.ql.cqn.Modifier; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.cds.ApplicationService; +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.cds.CqnService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.HandlerOrder; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.utils.OrderConstants; + +/** + * Intercepts CRUD events on application service entities that are projections on AICore entities + * and delegates them to the AICore service. Without this, the framework would try to forward these + * to the PersistenceService, which fails since AICore entities have no database tables. + */ +@ServiceName(value = "*", type = ApplicationService.class) +public class AICoreApplicationServiceHandler implements EventHandler { + + private final CqnService aiCoreService; + + public AICoreApplicationServiceHandler(CqnService aiCoreService) { + this.aiCoreService = aiCoreService; + } + + @On + @HandlerOrder(OrderConstants.On.FEATURE) + public void onRead(CdsReadEventContext context) { + String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); + if (sourceEntity == null) { + return; + } + CqnSelect rewritten = CQL.copy(context.getCqn(), entityModifier(sourceEntity)); + context.setResult(aiCoreService.run(rewritten)); + } + + @On + @HandlerOrder(OrderConstants.On.FEATURE) + public void onCreate(CdsCreateEventContext context) { + String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); + if (sourceEntity == null) { + return; + } + context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); + } + + @On + @HandlerOrder(OrderConstants.On.FEATURE) + public void onUpdate(CdsUpdateEventContext context) { + String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); + if (sourceEntity == null) { + return; + } + context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); + } + + @On + @HandlerOrder(OrderConstants.On.FEATURE) + public void onDelete(CdsDeleteEventContext context) { + String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); + if (sourceEntity == null) { + return; + } + context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); + } + + private String resolveAICoreSource(CdsEntity entity, CdsModel model) { + if (entity == null || !entity.isProjection()) { + return null; + } + return entity + .query() + .filter(q -> q.from().isRef()) + .map(q -> CqnAnalyzer.create(model).analyze(q).targetEntity()) + .map(CdsEntity::getQualifiedName) + .filter(name -> name.startsWith("AICore.")) + .orElse(null); + } + + private static Modifier entityModifier(String targetEntity) { + return new Modifier() { + @Override + public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) { + var copy = CQL.copy(ref); + copy.rootSegment().id(targetEntity); + return copy.build(); + } + }; + } +} 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..0549daa --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -0,0 +1,66 @@ +/* + * © 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.AICoreServiceImpl; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +abstract class AbstractCrudHandler implements EventHandler { + + protected final AICoreServiceImpl service; + + protected AbstractCrudHandler(AICoreServiceImpl service) { + this.service = service; + } + + protected String resolveResourceGroup(Map keys) { + return service.resolveResourceGroupFromKeys(keys); + } + + /** + * 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(String resourceGroupId) { + if (service.isProviderUser() || !service.isMultiTenancyEnabled()) { + return; + } + String currentTenant = service.currentTenantId(); + if (currentTenant == null) { + return; + } + BckndResourceGroup rg = service.getResourceGroupApi().get(resourceGroupId); + if (rg.getLabels() != null + && rg.getLabels().stream() + .anyMatch( + l -> + AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + && currentTenant.equals(l.getValue()))) { + return; + } + throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource not found"); + } + + 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..810068b --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -0,0 +1,49 @@ +/* + * © 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.client.DeploymentApi; +import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.services.EventContext; +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(AICoreService.DEFAULT_NAME) +public class ActionHandler extends AbstractCrudHandler { + + private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); + + public ActionHandler(AICoreServiceImpl service) { + super(service); + } + + @On(event = "stop", entity = AICoreService.DEPLOYMENTS) + public void onStop(EventContext context) { + Map keys = asMap(context.get("keys")); + String deploymentId = (String) keys.get(Deployments.ID); + String resourceGroupId = resolveResourceGroup(keys); + + DeploymentApi api = service.getDeploymentApi(); + AiDeploymentModificationRequest modRequest = + AiDeploymentModificationRequest.create().targetStatus(AiDeploymentTargetStatus.STOPPED); + api.modify(resourceGroupId, deploymentId, modRequest); + logger.debug("Stopped deployment {} in resource group {}", deploymentId, resourceGroupId); + context.setCompleted(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object obj) { + if (obj instanceof Map) { + return (Map) obj; + } + return Map.of(); + } +} 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..bafdbff --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -0,0 +1,145 @@ +/* + * © 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.client.ConfigurationApi; +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.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +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.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.cds.CqnService; +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(AICoreService.DEFAULT_NAME) +public class ConfigurationHandler extends AbstractCrudHandler { + + private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); + + private final ConfigurationApi configurationApi; + + public ConfigurationHandler(AICoreServiceImpl service) { + super(service); + this.configurationApi = service.getConfigurationApi(); + } + + @On(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS) + 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(merge(keys, values)); + ensureResourceGroupAccessible(resourceGroupId); + logger.debug( + "Reading configurations for resourceGroup={}, keys={}, values={}", + resourceGroupId, + keys, + values); + + String id = (String) keys.get(Configurations.ID); + if (id != null) { + AiConfiguration config = configurationApi.get(resourceGroupId, id); + context.setResult(List.of(toConfigurations(config, resourceGroupId))); + } else { + String scenarioId = (String) values.get(Configurations.SCENARIO_ID); + AiConfigurationList result = + 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(event = CqnService.EVENT_CREATE, entity = AICoreService.CONFIGURATIONS) + public void onCreate(CdsCreateEventContext context, List entries) { + List> results = new ArrayList<>(); + + for (Configurations entry : entries) { + String resourceGroupId = resolveResourceGroup(entry); + ensureResourceGroupAccessible(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 = 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..0b3df10 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -0,0 +1,246 @@ +/* + * © 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.client.DeploymentApi; +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.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +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.cds.CqnService; +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(AICoreService.DEFAULT_NAME) +public class DeploymentHandler extends AbstractCrudHandler { + + private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); + + private final DeploymentApi deploymentApi; + + public DeploymentHandler(AICoreServiceImpl service) { + super(service); + this.deploymentApi = service.getDeploymentApi(); + } + + @On(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS) + 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(merge(keys, values)); + ensureResourceGroupAccessible(resourceGroupId); + + String id = (String) keys.get(Deployments.ID); + if (id != null) { + AiDeploymentResponseWithDetails deployment = deploymentApi.get(resourceGroupId, id); + context.setResult(List.of(toDeployments(deployment, resourceGroupId))); + } else { + AiDeploymentList result = + deploymentApi.query(resourceGroupId, null, null, null, null, null, null, null); + context.setResult( + mapResources(result.getResources(), d -> toDeployments(d, resourceGroupId))); + } + } + + @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) + public void onCreate(CdsCreateEventContext context, List entries) { + List> results = new ArrayList<>(); + + for (Deployments entry : entries) { + String resourceGroupId = resolveResourceGroup(entry); + ensureResourceGroupAccessible(resourceGroupId); + String configurationId = entry.getConfigurationId(); + + AiDeploymentCreationRequest request = + AiDeploymentCreationRequest.create().configurationId(configurationId); + + if (entry.getTtl() != null) { + request.ttl(entry.getTtl()); + } + + var response = 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(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS) + 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(merge(keys, data)); + ensureResourceGroupAccessible(resourceGroupId); + + AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); + + if (data.getTargetStatus() != null) { + modRequest.targetStatus(AiDeploymentTargetStatus.fromValue(data.getTargetStatus())); + } + if (data.getConfigurationId() != null) { + modRequest.configurationId(data.getConfigurationId()); + } + + deploymentApi.modify(resourceGroupId, deploymentId, modRequest); + logger.debug("Updated deployment {} in resource group {}", deploymentId, resourceGroupId); + context.setResult(List.of(data)); + } + + @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS) + 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(keys); + ensureResourceGroupAccessible(resourceGroupId); + + 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/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..cef5666 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java @@ -0,0 +1,186 @@ +/* + * © 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.api.AICoreService; +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.cds.CqnService; +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(AICoreService.DEFAULT_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(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS) + public void readResourceGroups(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("resourceGroupId"); + if (id != null) { + Map rg = resourceGroups.get(id); + context.setResult(rg != null ? List.of(rg) : List.of()); + } else { + context.setResult(List.copyOf(resourceGroups.values())); + } + } + + @On(event = CqnService.EVENT_CREATE, entity = AICoreService.RESOURCE_GROUPS) + 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(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS) + 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(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS) + 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(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS) + 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(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) + 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(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS) + 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(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS) + 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(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS) + 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(event = CqnService.EVENT_CREATE, entity = AICoreService.CONFIGURATIONS) + 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..b0e7094 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.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.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.CdsData; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +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.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.cds.CqnService; +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(AICoreService.DEFAULT_NAME) +public class ResourceGroupHandler extends AbstractCrudHandler { + + private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); + + private final ResourceGroupApi resourceGroupApi; + + public ResourceGroupHandler(AICoreServiceImpl service) { + super(service); + this.resourceGroupApi = service.getResourceGroupApi(); + } + + @On(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS) + 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 = resourceGroupApi.get(resourceGroupId); + ensureOwnedByCurrentTenant(rg); + context.setResult(List.of(toMap(rg))); + } else { + List labelSelector = buildTenantLabelSelector(values); + BckndResourceGroupList result = + resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); + context.setResult(mapResources(result.getResources(), this::toMap)); + } + } + + @On(event = CqnService.EVENT_CREATE, entity = AICoreService.RESOURCE_GROUPS) + 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 -> AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.get("key"))); + + if (entry.getTenantId() != null && !userSuppliedTenantLabel) { + mergedLabels.add( + BckndResourceGroupLabel.create() + .key(AICoreServiceImpl.TENANT_LABEL_KEY) + .value(entry.getTenantId())); + } + + if (labels != null) { + mergedLabels.addAll(toSdkLabels(labels)); + } + + if (!mergedLabels.isEmpty()) { + request.labels(mergedLabels); + } + + resourceGroupApi.create(request); + logger.debug("Created resource group {}", resourceGroupId); + results.add(entry); + } + context.setResult(results); + } + + @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS) + 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(keys); + ensureOwnedByCurrentTenant(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)); + } + + resourceGroupApi.patch(resourceGroupId, patchRequest); + logger.debug("Updated resource group {}", resourceGroupId); + context.setResult(List.of(CdsData.create(data))); + } + + @On(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS) + 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(keys); + ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); + + resourceGroupApi.delete(resourceGroupId); + logger.debug("Deleted resource group {}", resourceGroupId); + context.setResult(List.of()); + } + + private String resolveResourceGroupId(Map keys) { + if (keys.containsKey(ResourceGroups.RESOURCE_GROUP_ID)) { + return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); + } + if (keys.containsKey(ResourceGroups.TENANT_ID)) { + return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); + } + return service.resourceGroup(); + } + + /** + * 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(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(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); + } + // In MT mode, restrict non-provider users to their own tenant + if (service.isMultiTenancyEnabled() && !service.isProviderUser()) { + String currentTenant = service.currentTenantId(); + if (currentTenant != null) { + return List.of(AICoreServiceImpl.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(BckndResourceGroup rg) { + if (service.isProviderUser()) { + return; + } + if (!service.isMultiTenancyEnabled()) { + return; + } + String currentTenant = service.currentTenantId(); + if (currentTenant == null) { + return; + } + if (rg.getLabels() != null + && rg.getLabels().stream() + .anyMatch( + l -> + AICoreServiceImpl.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 (AICoreServiceImpl.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..d2feaaa --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -0,0 +1,177 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; +import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.handler.EventHandler; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AICoreServiceConfigurationTest { + + @Mock private CdsRuntimeConfigurer configurer; + @Mock private CdsRuntime runtime; + @Mock private CdsEnvironment environment; + @Mock private ServiceCatalog serviceCatalog; + + /** + * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl + * (lines 116-123) with multi-tenancy disabled. + */ + @Test + void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(runtime.getEnvironment()).thenReturn(environment); + lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) + .thenAnswer(invocation -> invocation.getArgument(2)); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + MockAICoreServiceImpl mockService = + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, false); + when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + .thenReturn(mockService); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.eventHandlers(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); + verify(configurer, atLeastOnce()).eventHandler(captor.capture()); + + var handlers = captor.getAllValues(); + assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); + assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); + // No setup handler registered when MT is disabled + assert handlers.stream().noneMatch(h -> h instanceof MockAICoreSetupHandler); + } + + /** + * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl with + * multi-tenancy enabled — verifies that MockAICoreSetupHandler is registered (lines 119-121). + */ + @Test + void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(runtime.getEnvironment()).thenReturn(environment); + lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) + .thenAnswer(invocation -> invocation.getArgument(2)); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + MockAICoreServiceImpl mockService = + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, true); + when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + .thenReturn(mockService); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.eventHandlers(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); + verify(configurer, atLeastOnce()).eventHandler(captor.capture()); + + var handlers = captor.getAllValues(); + assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); + assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); + assert handlers.stream().anyMatch(h -> h instanceof MockAICoreSetupHandler); + } + + /** + * Tests detectMultiTenancy returning true when sidecar URL is set (the + * `if (sidecarUrl != null && !sidecarUrl.isBlank()) { return true; }` branch). + * This is exercised through services() with no AI Core binding present. + */ + @Test + void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { + String envKey = System.getenv("AICORE_SERVICE_KEY"); + org.junit.jupiter.api.Assumptions.assumeTrue( + envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) + .thenAnswer(invocation -> invocation.getArgument(2)); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + CdsProperties cdsProperties = new CdsProperties(); + CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); + CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); + sidecar.setUrl("http://localhost:4004"); + mt.setSidecar(sidecar); + cdsProperties.setMultiTenancy(mt); + when(environment.getCdsProperties()).thenReturn(cdsProperties); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.services(configurer); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockAICoreServiceImpl.class); + verify(configurer).service(captor.capture()); + assert captor.getValue().isMultiTenancyEnabled(); + } + + /** + * Tests detectMultiTenancy returning false when no sidecar URL and no DeploymentService. + */ + @Test + void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { + String envKey = System.getenv("AICORE_SERVICE_KEY"); + org.junit.jupiter.api.Assumptions.assumeTrue( + envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getEnvironment()).thenReturn(environment); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) + .thenAnswer(invocation -> invocation.getArgument(2)); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + CdsProperties cdsProperties = new CdsProperties(); + CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); + CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); + // No URL set - defaults to null + mt.setSidecar(sidecar); + cdsProperties.setMultiTenancy(mt); + when(environment.getCdsProperties()).thenReturn(cdsProperties); + when(serviceCatalog.getService(any(Class.class), any())).thenReturn(null); + + AICoreServiceConfiguration config = new AICoreServiceConfiguration(); + config.services(configurer); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(MockAICoreServiceImpl.class); + verify(configurer).service(captor.capture()); + assert !captor.getValue().isMultiTenancyEnabled(); + } +} 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..f5e3396 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -0,0 +1,261 @@ +/* + * © 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.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.AICoreService; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the happy paths of {@link AICoreServiceImpl#deploymentId(String, + * ModelDeploymentSpec)}: 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 AICoreServiceImpl service; + + private final ModelDeploymentSpec spec = + new ModelDeploymentSpec(SCENARIO, "exec", CONFIG_NAME, List.of(), d -> true); + + private String cacheKey() { + // Derive via the production helper rather than hardcoding RG + "::" + CONFIG_NAME so a + // change to the cache-key format is caught here instead of silently passing wrong-path. + return AICoreServiceImpl.deploymentCacheKey(RG, spec); + } + + @BeforeEach + void setUp() { + deploymentApi = mock(DeploymentApi.class); + configurationApi = mock(ConfigurationApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + AiCoreService sdkService = mock(AiCoreService.class); + + CdsRuntime runtime = mock(CdsRuntime.class); + CdsEnvironment env = mock(CdsEnvironment.class); + when(runtime.getEnvironment()).thenReturn(env); + // Use small retry counts so failures don't slow tests. + when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) + .thenReturn(1); + when(env.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())) + .thenReturn(1L); + when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + service = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + runtime, + false, + deploymentApi, + configurationApi, + resourceGroupApi, + sdkService); + } + + @Test + void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() { + service.getResourceGroupDeploymentCache().put(cacheKey(), DEPLOYMENT_ID); + + AiDeploymentResponseWithDetails running = mock(AiDeploymentResponseWithDetails.class); + when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); + when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); + + String result = service.deploymentId(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() { + String otherDeployment = "dep-456"; + service.getResourceGroupDeploymentCache().put(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 = service.deploymentId(RG, spec); + + assertThat(result).isEqualTo(otherDeployment); + assertThat(service.getResourceGroupDeploymentCache()) + .containsEntry(cacheKey(), otherDeployment); + verify(deploymentApi, never()).create(any(), any()); + } + + @Test + void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() { + // Transient 5xx must NOT invalidate a potentially valid cache entry. The exception is + // propagated so the caller's retry/backoff policy can handle it. + service.getResourceGroupDeploymentCache().put(cacheKey(), "still-valid-id"); + + OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); + when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.deploymentId(RG, spec)) + .isSameAs(serverError); + + assertThat(service.getResourceGroupDeploymentCache()) + .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() { + 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 = service.deploymentId(RG, spec); + + assertThat(result).isEqualTo(DEPLOYMENT_ID); + assertThat(service.getResourceGroupDeploymentCache()).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 = service.deploymentId(RG, spec); + String second = service.deploymentId(RG, spec); + + assertThat(first).isEqualTo(DEPLOYMENT_ID); + assertThat(second).isEqualTo(DEPLOYMENT_ID); + // First call queries, second call hits the cache and only verifies via get. + 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() { + // Even with MT enabled, a null tenantId should fall back to the default resource group. + CdsRuntime rtMt = mock(CdsRuntime.class); + CdsEnvironment envMt = mock(CdsEnvironment.class); + when(rtMt.getEnvironment()).thenReturn(envMt); + when(envMt.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())).thenReturn(1); + when(envMt.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())).thenReturn(1L); + when(envMt.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("my-default"); + when(envMt.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + AICoreServiceImpl mtService = + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + rtMt, + true, // multi-tenancy enabled + deploymentApi, + configurationApi, + resourceGroupApi, + mock(AiCoreService.class)); + + String result = mtService.resourceGroupForTenant(null); + assertThat(result).isEqualTo("my-default"); + } + + @Test + void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { + // Single-tenancy always returns default regardless of the tenantId passed. + String result = service.resourceGroupForTenant("any-tenant"); + assertThat(result).isEqualTo("default"); + } + + @Test + void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { + // Empty deployment list → falls through to create path. + 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); + + // Existing config with the same name, so createConfiguration is skipped. + 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 = service.deploymentId(RG, spec); + + assertThat(result).isEqualTo(DEPLOYMENT_ID); + assertThat(service.getResourceGroupDeploymentCache()).containsEntry(cacheKey(), DEPLOYMENT_ID); + verify(configurationApi, never()).create(any(), any()); + verify(deploymentApi).create(eq(RG), any()); + } +} 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..898e1db --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java @@ -0,0 +1,188 @@ +/* + * © 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.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +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 { + + @Test + void notReadyYet_topLevel403_returnsTrue() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(403); + + assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + } + + @Test + void notReadyYet_topLevel412_returnsTrue() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(412); + + assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + } + + @Test + void notReadyYet_topLevel404_returnsTrue() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(404); + + assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + } + + @Test + void notReadyYet_topLevel500_returnsFalse() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(500); + + assertThat(AICoreServiceImpl.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(AICoreServiceImpl.notReadyYet(outer)).isTrue(); + } + + @Test + void notReadyYet_nullStatusCodeOnAllLevels_returnsFalse() { + OpenApiRequestException e = mock(OpenApiRequestException.class); + when(e.statusCode()).thenReturn(null); + + assertThat(AICoreServiceImpl.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 = AICoreServiceImpl.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 clearTenantCacheRemovesAllRelatedEntries() throws Exception { + String tenantId = "tenant-1"; + String resourceGroupId = "cds-tenant-1"; + + AICoreServiceImpl service = freshService(); + Cache tenantCache = readCache(service, "tenantResourceGroupCache"); + Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); + ConcurrentHashMap deploymentLocks = readLocks(service); + + tenantCache.put(tenantId, resourceGroupId); + deploymentCache.put(resourceGroupId, "deployment-id"); + deploymentLocks.put(resourceGroupId, new Object()); + + service.clearTenantCache(tenantId); + + assertThat(tenantCache.asMap()).doesNotContainKey(tenantId); + assertThat(deploymentCache.asMap()).doesNotContainKey(resourceGroupId); + assertThat(deploymentLocks).doesNotContainKey(resourceGroupId); + } + + @Test + void clearTenantCacheLeavesOtherTenantsUntouched() throws Exception { + String tenantA = "tenant-a"; + String resourceGroupA = "cds-tenant-a"; + String tenantB = "tenant-b"; + String resourceGroupB = "cds-tenant-b"; + + AICoreServiceImpl service = freshService(); + Cache tenantCache = readCache(service, "tenantResourceGroupCache"); + Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); + ConcurrentHashMap deploymentLocks = readLocks(service); + + tenantCache.put(tenantA, resourceGroupA); + tenantCache.put(tenantB, resourceGroupB); + deploymentCache.put(resourceGroupA, "deployment-a"); + deploymentCache.put(resourceGroupB, "deployment-b"); + deploymentLocks.put(resourceGroupA, new Object()); + deploymentLocks.put(resourceGroupB, new Object()); + + service.clearTenantCache(tenantA); + + assertThat(tenantCache.asMap()).doesNotContainKey(tenantA).containsKey(tenantB); + assertThat(deploymentCache.asMap()) + .doesNotContainKey(resourceGroupA) + .containsKey(resourceGroupB); + assertThat(deploymentLocks).doesNotContainKey(resourceGroupA).containsKey(resourceGroupB); + } + + @Test + void clearTenantCacheIsNoOpForUnknownTenant() throws Exception { + String resourceGroupId = "cds-tenant-1"; + + AICoreServiceImpl service = freshService(); + Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); + ConcurrentHashMap deploymentLocks = readLocks(service); + + deploymentCache.put(resourceGroupId, "deployment-id"); + deploymentLocks.put(resourceGroupId, new Object()); + + service.clearTenantCache("unknown-tenant"); + + assertThat(deploymentCache.asMap()).containsKey(resourceGroupId); + assertThat(deploymentLocks).containsKey(resourceGroupId); + } + + private static AICoreServiceImpl freshService() throws Exception { + AICoreServiceImpl service = mock(AICoreServiceImpl.class, CALLS_REAL_METHODS); + setField(service, "tenantResourceGroupCache", Caffeine.newBuilder().build()); + setField(service, "resourceGroupDeploymentCache", Caffeine.newBuilder().build()); + setField(service, "deploymentLocks", new ConcurrentHashMap<>()); + return service; + } + + @SuppressWarnings("unchecked") + private static Cache readCache(AICoreServiceImpl service, String fieldName) + throws Exception { + Field field = AICoreServiceImpl.class.getDeclaredField(fieldName); + field.setAccessible(true); + return (Cache) field.get(service); + } + + @SuppressWarnings("unchecked") + private static ConcurrentHashMap readLocks(AICoreServiceImpl service) + throws Exception { + Field field = AICoreServiceImpl.class.getDeclaredField("deploymentLocks"); + field.setAccessible(true); + return (ConcurrentHashMap) field.get(service); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = AICoreServiceImpl.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} 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..f475e5e --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java @@ -0,0 +1,179 @@ +/* + * © 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.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +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.util.HashMap; +import java.util.List; +import java.util.Map; +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 AICoreServiceImpl service; + @Mock private ResourceGroupApi resourceGroupApi; + @Mock private UnsubscribeEventContext unsubscribeContext; + + private Map tenantCache; + private AICoreSetupHandler cut; + + @BeforeEach + void setUp() { + tenantCache = new HashMap<>(); + when(service.getTenantResourceGroupCache()).thenReturn(tenantCache); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + when(unsubscribeContext.getTenant()).thenReturn(TENANT); + cut = new AICoreSetupHandler(service); + } + + @Test + void cacheHit_deletesAndClears() { + tenantCache.put(TENANT, RG_ID); + + cut.beforeUnsubscribe(unsubscribeContext); + + verify(resourceGroupApi).delete(RG_ID); + verify(resourceGroupApi, never()).getAll(any(), any(), any(), any(), any(), any(), any()); + verify(service).clearTenantCache(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(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + TENANT); + verify(resourceGroupApi).delete(RG_ID); + verify(service).clearTenantCache(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()); + verify(service).clearTenantCache(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()); + verify(service).clearTenantCache(TENANT); + } + + @Test + void deleteReturns404_treatedAsSuccess() { + tenantCache.put(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); + verify(service).clearTenantCache(TENANT); + } + + @Test + void deleteReturnsOther5xx_propagatesAsServiceException() { + tenantCache.put(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. + verify(service).clearTenantCache(TENANT); + } + + @Test + void unsubscribeTwice_secondCallIsNoOp() { + tenantCache.put(TENANT, RG_ID); + // First call uses cache, deletes successfully and clears (we simulate clearTenantCache + // by removing from the underlying map). + org.mockito.Mockito.doAnswer( + inv -> { + tenantCache.remove(TENANT); + return null; + }) + .when(service) + .clearTenantCache(TENANT); + + 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()); + verify(service, times(2)).clearTenantCache(TENANT); + } + + @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()); + verify(service).clearTenantCache(TENANT); + } + + @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; + } +} 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..a8dd214 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -0,0 +1,106 @@ +/* + * © 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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.runtime.CdsRuntime; +import org.junit.jupiter.api.Test; + +class MockAICoreServiceImplTest { + + private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) { + CdsRuntime runtime = mock(CdsRuntime.class); + CdsEnvironment env = mock(CdsEnvironment.class); + when(runtime.getEnvironment()).thenReturn(env); + when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("test-rg"); + when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("prefix-"); + return new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + } + + @Test + void defaultConstructor_setsMultiTenancyFalse() { + CdsRuntime runtime = mock(CdsRuntime.class); + CdsEnvironment env = mock(CdsEnvironment.class); + when(runtime.getEnvironment()).thenReturn(env); + when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) + .thenReturn("cds-"); + + MockAICoreServiceImpl service = new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + assertThat(service.isMultiTenancyEnabled()).isFalse(); + } + + @Test + void mtConstructor_setsMultiTenancyTrue() { + MockAICoreServiceImpl service = createService(true); + assertThat(service.isMultiTenancyEnabled()).isTrue(); + } + + @Test + void resourceGroupForTenant_mtDisabled_alwaysReturnsDefault() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.resourceGroupForTenant("tenant-x")).isEqualTo("test-rg"); + assertThat(service.resourceGroupForTenant("tenant-y")).isEqualTo("test-rg"); + } + + @Test + void resourceGroupForTenant_mtEnabled_returnsPrefixedTenantId() { + MockAICoreServiceImpl service = createService(true); + String rg = service.resourceGroupForTenant("my-tenant"); + assertThat(rg).isEqualTo("prefix-my-tenant"); + } + + @Test + void resourceGroupForTenant_mtEnabled_cachesResult() { + MockAICoreServiceImpl service = createService(true); + String first = service.resourceGroupForTenant("t1"); + String second = service.resourceGroupForTenant("t1"); + assertThat(first).isSameAs(second); + assertThat(service.getTenantResourceGroupCache()).containsKey("t1"); + } + + @Test + void clearTenantCache_removesCorrectEntries() { + MockAICoreServiceImpl service = createService(true); + service.resourceGroupForTenant("t1"); + service.resourceGroupForTenant("t2"); + var spec = new com.sap.cds.feature.aicore.api.ModelDeploymentSpec( + "scenario", "exec", "cfg1", java.util.List.of(), d -> true); + service.deploymentId("prefix-t1", spec); + + service.clearTenantCache("t1"); + + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("t1"); + assertThat(service.getTenantResourceGroupCache()).containsKey("t2"); + assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKeys("prefix-t1::cfg1"); + } + + @Test + void getRetry_returnsNonNull() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getRetry()).isNotNull(); + } + + @Test + void getDefaultResourceGroup_readsFromConfig() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getDefaultResourceGroup()).isEqualTo("test-rg"); + } + + @Test + void getResourceGroupPrefix_readsFromConfig() { + MockAICoreServiceImpl service = createService(false); + assertThat(service.getResourceGroupPrefix()).isEqualTo("prefix-"); + } +} 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..03e0579 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -0,0 +1,69 @@ +/* + * © 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.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +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.CdsReadEventContext; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ConfigurationHandlerTest { + + @Mock private AICoreServiceImpl service; + @Mock private ConfigurationApi configurationApi; + @Mock private CdsReadEventContext context; + @Mock private CqnSelect select; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void onRead_nullResources_returnsEmptyListWithoutNpe() { + when(service.getConfigurationApi()).thenReturn(configurationApi); + when(context.getCqn()).thenReturn(select); + when(context.getModel()).thenReturn(model); + when(analyzer.analyze(select)).thenReturn(analysisResult); + when(analysisResult.targetKeys()).thenReturn(new HashMap<>()); + when(analysisResult.targetValues()).thenReturn(new HashMap<>()); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("default"); + + AiConfigurationList listWithNullResources = mock(AiConfigurationList.class); + when(listWithNullResources.getResources()).thenReturn(null); + when(configurationApi.query(any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(listWithNullResources); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + + ConfigurationHandler handler = new ConfigurationHandler(service); + handler.onRead(context); + } + + @SuppressWarnings("unchecked") + ArgumentCaptor>> captor = ArgumentCaptor.forClass(List.class); + verify(context).setResult(captor.capture()); + assertThat(captor.getValue()).isEmpty(); + } +} 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..cc26a3c --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -0,0 +1,193 @@ +/* + * © 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.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.client.DeploymentApi; +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.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.ql.cqn.AnalysisResult; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnUpdate; +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.CdsUpdateEventContext; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DeploymentHandlerTest { + + @Mock private AICoreServiceImpl service; + @Mock private DeploymentApi deploymentApi; + @Mock private CdsUpdateEventContext updateContext; + @Mock private CdsCreateEventContext createContext; + + private DeploymentHandler cut; + + @BeforeEach + void setup() { + when(service.getDeploymentApi()).thenReturn(deploymentApi); + cut = new DeploymentHandler(service); + } + + @Test + void onUpdate_emptyEntries_throwsBadRequest() { + List entries = List.of(); + + assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) + .isInstanceOfSatisfying( + ServiceException.class, + e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) + .hasMessageContaining("No update payload provided"); + + verifyNoInteractions(deploymentApi); + } + + @Test + void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { + List entries = List.of(Deployments.of(Map.of("ttl", "1d"))); + + assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) + .isInstanceOfSatisfying( + ServiceException.class, + e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) + .hasMessageContaining("targetStatus") + .hasMessageContaining("configurationId"); + + verifyNoInteractions(deploymentApi); + } + + @Test + void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { + Deployments data = Deployments.create(); + data.setTargetStatus("STOPPED"); + List entries = List.of(data); + + CqnUpdate cqnUpdate = mock(CqnUpdate.class); + CdsModel model = mock(CdsModel.class); + CqnAnalyzer analyzer = mock(CqnAnalyzer.class); + AnalysisResult analysisResult = mock(AnalysisResult.class); + + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + Map keys = new HashMap<>(); + keys.put(Deployments.ID, "dep-123"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-1"); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + cut.onUpdate(updateContext, entries); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("rg-1"), eq("dep-123"), captor.capture()); + assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED"); + } + + @Test + void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { + Deployments data = Deployments.create(); + data.setConfigurationId("config-456"); + List entries = List.of(data); + + CqnUpdate cqnUpdate = mock(CqnUpdate.class); + CdsModel model = mock(CdsModel.class); + CqnAnalyzer analyzer = mock(CqnAnalyzer.class); + AnalysisResult analysisResult = mock(AnalysisResult.class); + + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + Map keys = new HashMap<>(); + keys.put(Deployments.ID, "dep-789"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-2"); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + cut.onUpdate(updateContext, entries); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("rg-2"), eq("dep-789"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456"); + } + + @Test + void onCreate_createsDeploymentWithConfigurationId() { + Deployments entry = Deployments.create(); + entry.setConfigurationId("cfg-1"); + entry.put(Deployments.RESOURCE_GROUP, Map.of("resourceGroupId", "rg-test")); + List entries = List.of(entry); + + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("new-dep-id"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-test"); + when(service.isProviderUser()).thenReturn(true); + when(deploymentApi.create(eq("rg-test"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); + + cut.onCreate(createContext, entries); + + verify(createContext).setResult(any(List.class)); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("rg-test"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1"); + } + + @Test + void onCreate_withTtl_setsTtlOnRequest() { + Deployments entry = Deployments.create(); + entry.setConfigurationId("cfg-2"); + entry.setTtl("PT24H"); + List entries = List.of(entry); + + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("dep-ttl"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-default"); + when(service.isProviderUser()).thenReturn(true); + when(deploymentApi.create(eq("rg-default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); + + cut.onCreate(createContext, entries); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("rg-default"), captor.capture()); + assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); + } +} 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..14a828b --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -0,0 +1,457 @@ +/* + * © 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.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.feature.aicore.core.AICoreServiceImpl; +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.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ResourceGroupHandlerTest { + + @Mock private AICoreServiceImpl service; + @Mock private ResourceGroupApi resourceGroupApi; + @Mock private CdsCreateEventContext createContext; + + private ResourceGroupHandler handler; + + @BeforeEach + void setUp() { + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + handler = new ResourceGroupHandler(service); + } + + @Test + void onCreate_withTenantIdOnly_setsOnlyTenantLabel() { + Map entry = Map.of("resourceGroupId", "rg-1", "tenantId", "tenant-a"); + List entries = List.of(ResourceGroups.of(entry)); + + handler.onCreate(createContext, entries); + + BckndResourceGroupsPostRequest request = captureCreateRequest(); + assertThat(request.getResourceGroupId()).isEqualTo("rg-1"); + assertThat(request.getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-a")); + } + + @Test + void onCreate_withLabelsOnly_setsOnlyUserLabels() { + Map entry = + Map.of( + "resourceGroupId", + "rg-2", + "labels", + List.of(Map.of("key", "env", "value", "prod"), Map.of("key", "team", "value", "ai"))); + List entries = List.of(ResourceGroups.of(entry)); + + handler.onCreate(createContext, entries); + + BckndResourceGroupsPostRequest request = captureCreateRequest(); + assertThat(request.getResourceGroupId()).isEqualTo("rg-2"); + assertThat(request.getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly(tuple("env", "prod"), tuple("team", "ai")); + } + + @Test + void onCreate_withTenantIdAndLabels_keepsTenantLabelAndUserLabels() { + Map entry = + Map.of( + "resourceGroupId", + "rg-3", + "tenantId", + "tenant-b", + "labels", + List.of(Map.of("key", "env", "value", "prod"))); + List entries = List.of(ResourceGroups.of(entry)); + + handler.onCreate(createContext, entries); + + BckndResourceGroupsPostRequest request = captureCreateRequest(); + // Tenant label first, then user-supplied labels — and tenant label is NOT lost. + assertThat(request.getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly( + tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-b"), tuple("env", "prod")); + } + + @Test + void onCreate_userSuppliedTenantLabelTakesPrecedence() { + Map entry = + Map.of( + "resourceGroupId", + "rg-4", + "tenantId", + "tenant-auto", + "labels", + List.of(Map.of("key", AICoreServiceImpl.TENANT_LABEL_KEY, "value", "tenant-user"))); + List entries = List.of(ResourceGroups.of(entry)); + + handler.onCreate(createContext, entries); + + BckndResourceGroupsPostRequest request = captureCreateRequest(); + assertThat(request.getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-user")); + } + + @Nested + @ExtendWith(MockitoExtension.class) + class OnUpdateTests { + + @Mock private CdsUpdateEventContext updateContext; + @Mock private CqnUpdate cqnUpdate; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void onUpdate_withLabels_callsPatchWithLabels() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-upd"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + + Map data = new HashMap<>(); + data.put(ResourceGroups.LABELS, List.of(Map.of("key", "env", "value", "staging"))); + when(cqnUpdate.entries()).thenReturn(List.of(data)); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("my-tenant"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-upd")).thenReturn(rg); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("my-tenant"); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onUpdate(updateContext); + } + + 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() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-nolabel"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); + when(updateContext.getCqn()).thenReturn(cqnUpdate); + when(updateContext.getModel()).thenReturn(model); + + Map data = new HashMap<>(); + // no labels in update payload + when(cqnUpdate.entries()).thenReturn(List.of(data)); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg); + when(service.isProviderUser()).thenReturn(true); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onUpdate(updateContext); + } + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture()); + assertThat(captor.getValue().getLabels()).isNullOrEmpty(); + } + } + + @Nested + @ExtendWith(MockitoExtension.class) + class BuildTenantLabelSelectorTests { + + @Mock private CdsReadEventContext readContext; + @Mock private CqnSelect cqnSelect; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void readAll_withTenantIdFilter_usesLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + values.put(ResourceGroups.TENANT_ID, "tenant-x"); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + @SuppressWarnings("unchecked") + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); + verify(resourceGroupApi) + .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); + assertThat(selectorCaptor.getValue()) + .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=tenant-x"); + } + + @Test + void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.isProviderUser()).thenReturn(false); + when(service.currentTenantId()).thenReturn("current-tenant"); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + @SuppressWarnings("unchecked") + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); + verify(resourceGroupApi) + .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); + assertThat(selectorCaptor.getValue()) + .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=current-tenant"); + } + + @Test + void readAll_multiTenancy_nullTenant_noLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.isProviderUser()).thenReturn(false); + when(service.currentTenantId()).thenReturn(null); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); + } + + @Test + void readAll_singleTenancy_noLabelSelector() { + Map keys = new HashMap<>(); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + BckndResourceGroupList result = mock(BckndResourceGroupList.class); + when(result.getResources()).thenReturn(List.of()); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(result); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + handler.onRead(readContext); + } + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); + } + } + + @Nested + @ExtendWith(MockitoExtension.class) + class EnsureOwnedByCurrentTenantTests { + + @Mock private CdsReadEventContext readContext; + @Mock private CqnSelect cqnSelect; + @Mock private CdsModel model; + @Mock private CqnAnalyzer analyzer; + @Mock private AnalysisResult analysisResult; + + @Test + void readById_providerUser_allowsAccessToAnyRg() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-any"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(true); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-any"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-any")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + + @Test + void readById_singleTenancy_allowsAccess() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-single"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-single"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-single")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + + @Test + void readById_multiTenancy_wrongTenant_throws404() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-other"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-b"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-other")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatThrownBy(() -> handler.onRead(readContext)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + } + + @Test + void readById_multiTenancy_matchingTenant_allowsAccess() { + Map keys = new HashMap<>(); + keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-mine"); + Map values = new HashMap<>(); + when(analysisResult.targetKeys()).thenReturn(keys); + when(analysisResult.targetValues()).thenReturn(values); + when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); + when(readContext.getCqn()).thenReturn(cqnSelect); + when(readContext.getModel()).thenReturn(model); + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-a"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(rg.getResourceGroupId()).thenReturn("rg-mine"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-mine")).thenReturn(rg); + + try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { + staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); + } + } + } + + private BckndResourceGroupsPostRequest captureCreateRequest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + return captor.getValue(); + } +} 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..d1171ec --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -0,0 +1,154 @@ +/* + * © 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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.ServiceException; +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; + +/** + * Unit tests for the tenant-scoping guard methods in {@link AbstractCrudHandler}. + * Tests the {@code ensureResourceGroupAccessible} logic which is used by + * ConfigurationHandler and DeploymentHandler. + */ +@ExtendWith(MockitoExtension.class) +class TenantScopingTest { + + @Mock private AICoreServiceImpl service; + @Mock private ResourceGroupApi resourceGroupApi; + + /** Concrete subclass to expose the protected method for testing. */ + private static class TestableHandler extends AbstractCrudHandler { + TestableHandler(AICoreServiceImpl service) { + super(service); + } + + void callEnsureResourceGroupAccessible(String resourceGroupId) { + ensureResourceGroupAccessible(resourceGroupId); + } + } + + private TestableHandler handler; + + @BeforeEach + void setUp() { + handler = new TestableHandler(service); + } + + // ── ensureResourceGroupAccessible ────────────────────────────────────────── + + @Test + void providerUser_allowsAccessToAnyResourceGroup() { + when(service.isProviderUser()).thenReturn(true); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void singleTenancy_allowsAccessToAnyResourceGroup() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(false); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void multiTenancy_nullTenant_allowsAccess() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn(null); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + .doesNotThrowAnyException(); + verify(resourceGroupApi, never()).get("any-rg"); + } + + @Test + void multiTenancy_matchingTenantLabel_allowsAccess() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-a"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-for-a")).thenReturn(rg); + + assertThatCode(() -> handler.callEnsureResourceGroupAccessible("rg-for-a")) + .doesNotThrowAnyException(); + } + + @Test + void multiTenancy_nonMatchingTenantLabel_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn("tenant-b"); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get("rg-for-b")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-for-b")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + @Test + void multiTenancy_noLabels_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getLabels()).thenReturn(null); + when(resourceGroupApi.get("rg-no-labels")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-no-labels")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } + + @Test + void multiTenancy_emptyLabels_throws404() { + when(service.isProviderUser()).thenReturn(false); + when(service.isMultiTenancyEnabled()).thenReturn(true); + when(service.currentTenantId()).thenReturn("tenant-a"); + when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getLabels()).thenReturn(List.of()); + when(resourceGroupApi.get("rg-empty-labels")).thenReturn(rg); + + assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-empty-labels")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); + } +} diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md new file mode 100644 index 0000000..9b59766 --- /dev/null +++ b/cds-feature-recommendations/README.md @@ -0,0 +1,169 @@ +# 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 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 + +``` + +### Prerequisites + +- 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)) +- Entity must be **draft-enabled** (`@odata.draft.enabled`) +- At least one field annotated with a **value list** +- The `@cap-js/ai` CDS plugin must be installed (provides the model enhancement that adds `SAP_Recommendations` as a navigation property) + +### 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. Without this plugin, predictions will be computed but not serialized in OData responses. + +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 + +Recommendations are triggered for fields annotated with `@Common.ValueList`, `@Common.ValueListWithFixedValues`, or whose association target has `@cds.odata.valuelist`: + +```cds +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 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; +} +``` + +## Configuration + +```yaml +cds: + requires: + recommendations: + contextRowLimit: 2000 # Max historical rows used as training context +``` + +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 + +| Category | Types | +| -------- | --------------------------------------------------------- | +| String | `String`, `LargeString`, `UUID` | +| Numeric | `Integer`, `Int16`, `Int32`, `Int64`, `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/) 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..4d7f75e --- /dev/null +++ b/cds-feature-recommendations/pom.xml @@ -0,0 +1,150 @@ + + + 4.0.0 + + + com.sap.cds + cds-starter-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 + test + + + + + ${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..e7ea5d5 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -0,0 +1,148 @@ +/* + * © 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.aicore.api.AICoreService; +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 final AICoreService aiCoreService; + private final RecommendationClientResolver clientResolver; + 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( + AICoreService aiCoreService, RecommendationClientResolver clientResolver) { + this.aiCoreService = aiCoreService; + this.clientResolver = clientResolver; + } + + 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 (!Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) { + return; + } + + 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.contextColumns().isEmpty()) { + logger.debug("No suitable context columns found, skipping predictions."); + return; + } + + PersistenceService db = + context + .getServiceCatalog() + .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); + if (contextRows.size() < 2) { + logger.debug("Not enough context rows (minimum 2), skipping predictions."); + return; + } + + CdsData predictRow = builder.buildPredictRow(row); + if (predictRow == null) { + logger.debug("Current row already has values for all prediction columns, skipping."); + return; + } + + List allRows = builder.assembleRows(contextRows, predictRow, row); + + RecommendationClient client = clientResolver.resolve(aiCoreService); + List predictions = + client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); + + 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; + } + + List missingPredictionElementNames = + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); + 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..27498bb --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -0,0 +1,49 @@ +/* + * © 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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +class MockRecommendationClient implements RecommendationClient { + + private final Random random = new Random(); + + @Override + public List predict( + List rows, List predictionColumns, String indexColumn) { + List predictions = new ArrayList<>(); + for (CdsData row : rows) { + Map prediction = new HashMap<>(); + boolean addPrediction = false; + for (String col : predictionColumns) { + if ("[PREDICT]".equals(row.get(col))) { + addPrediction = true; + List availableValues = + rows.stream() + .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) + .map(r -> r.get(col)) + .toList(); + Object contextValue = + availableValues.isEmpty() + ? null + : availableValues.get(random.nextInt(availableValues.size())); + Map predictionEntry = new HashMap<>(); + predictionEntry.put("prediction", contextValue); + prediction.put(col, List.of(predictionEntry)); + } + } + if (addPrediction) { + prediction.put(indexColumn, row.get(indexColumn)); + predictions.add(CdsData.create(prediction)); + } + } + return predictions; + } +} 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..879f0fe --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -0,0 +1,54 @@ +/* + * © 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.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.core.MockAICoreServiceImpl; +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.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +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(); + + AICoreService aiCoreService = + serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + if (aiCoreService == null) { + logger.info("No AICoreService found, skipping Fiori recommendation handler registration."); + return; + } + + RecommendationClientResolver resolver = + aiCoreService instanceof MockAICoreServiceImpl + ? service -> new MockRecommendationClient() + : RecommendationConfiguration::resolveRptClient; + + FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + configurer.eventHandler(handler); + configurer.eventHandler(new RecommendationModelChangedHandler(handler)); + } + + private static RecommendationClient resolveRptClient(AICoreService service) { + String resourceGroup = service.resourceGroup(); + String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + return new RptInferenceClient( + service.inferenceClient(resourceGroup, deploymentId), + ((AbstractAICoreService) service).getRetry()); + } +} 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..a158123 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -0,0 +1,188 @@ +/* + * © 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.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +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, builds the context query, and prepares rows for the AI model. + */ +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 SYNTHETIC_KEY_COLUMN = "SAP_RECOMMENDATIONS_ID"; + 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; + private final boolean syntheticKeyNeeded; + private final String indexColumn; + + 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(); + this.syntheticKeyNeeded = + keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0))); + this.indexColumn = + syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID"); + } + + List predictionElementNames() { + return predictionElementNames; + } + + List contextColumns() { + return contextColumns; + } + + String indexColumn() { + return indexColumn; + } + + boolean syntheticKeyNeeded() { + return syntheticKeyNeeded; + } + + CqnSelect buildContextQuery() { + List selectColumns = new ArrayList<>(contextColumns); + for (String key : keyNames) { + if (!selectColumns.contains(key)) { + selectColumns.add(key); + } + } + var select = + Select.from(target.getQualifiedName()) + .columns(selectColumns.toArray(String[]::new)) + .where( + predictionElementNames.stream() + .map(col -> CQL.get(col).isNotNull()) + .collect(CQL.withAnd())) + .limit(contextRowLimit); + target + .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; + } + + CdsData buildPredictRow(CdsData row) { + if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { + return null; + } + Map predictRow = new HashMap<>(row); + Drafts.ELEMENTS.forEach(predictRow::remove); + for (String col : predictionElementNames) { + predictRow.putIfAbsent(col, "[PREDICT]"); + } + return CdsData.create(predictRow); + } + + private String computeSyntheticKey(Map row) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keyNames.size(); i++) { + if (i > 0) { + sb.append('\0'); + } + sb.append(keyNames.get(i)); + sb.append('\0'); + Object value = row.get(keyNames.get(i)); + if (value != null) { + sb.append(value); + } + } + return sb.toString(); + } + + List assembleRows(List contextRows, CdsData predictRow, CdsData currentRow) { + List allRows = new ArrayList<>(); + if (syntheticKeyNeeded) { + for (CdsData contextRow : contextRows) { + contextRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(contextRow)); + allRows.add(contextRow); + } + predictRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(currentRow)); + } else { + allRows.addAll(contextRows); + } + allRows.add(predictRow); + return allRows; + } + + private List computePredictionElements() { + return rowType + .elements() + .filter( + byAnnotation(VALUE_LIST_ANNOTATION) + .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) + .filter(e -> !e.getType().isAssociation()) + .map(CdsElement::getName) + .toList(); + } + + private List computeContextColumns() { + return rowType + .concreteNonAssociationElements() + .filter(e -> e.getType().isSimple()) + .filter( + e -> SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) + .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) + .map(CdsElement::getName) + .toList(); + } +} 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/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java new file mode 100644 index 0000000..8694745 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -0,0 +1,12 @@ +/* + * © 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 { + + List predict(List rows, List predictionColumns, String indexColumn); +} 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..ecc6837 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -0,0 +1,12 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +import com.sap.cds.feature.aicore.api.AICoreService; + +@FunctionalInterface +public interface RecommendationClientResolver { + + RecommendationClient resolve(AICoreService aiCoreService); +} 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..2865dd3 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -0,0 +1,118 @@ +/* + * © 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.services.draft.Drafts; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import io.github.resilience4j.retry.Retry; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +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
+ * AICoreService service = ...;
+ * String rg = service.resourceGroup();
+ * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
+ * RptInferenceClient client =
+ *     new RptInferenceClient(service.inferenceClient(rg, deploymentId),
+ *         ((AbstractAICoreService) service).getRetry());
+ * List predictions = client.predict(rows, List.of("targetColumn"), "ID");
+ * }
+ */ +public class RptInferenceClient implements RecommendationClient { + + private static final Logger logger = LoggerFactory.getLogger(RptInferenceClient.class); + + private static final Set MANAGED_FIELDS = + Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); + + private final DefaultApi api; + private final Retry retry; + + public RptInferenceClient(ApiClient apiClient, Retry retry) { + this.api = + new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); + this.retry = retry; + } + + @Override + public List predict( + List rows, List predictionColumns, String indexColumn) { + PredictRequestPayload request = buildRequest(rows, predictionColumns, indexColumn); + logger.debug( + "Sending prediction request for {} rows, {} target columns", + rows.size(), + predictionColumns.size()); + return Retry.decorateSupplier( + retry, + () -> { + var response = api.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(); + } + + private static PredictRequestPayload buildRequest( + List rows, List predictionColumns, String indexColumn) { + var targetColumns = + predictionColumns.stream() + .map( + col -> + TargetColumnConfig.create() + .name(col) + .predictionPlaceholder(PredictionPlaceholder.create("[PREDICT]")) + .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) + .toList(); + + var sdkRows = + rows.stream() + .map( + row -> { + Map sdkRow = new HashMap<>(); + row.forEach( + (k, v) -> { + if (v != null + && !Drafts.ELEMENTS.contains(k) + && !MANAGED_FIELDS.contains(k)) { + sdkRow.put(k, RowsInnerValue.create(v.toString())); + } + }); + for (String target : predictionColumns) { + if (!row.containsKey(target) || row.get(target) == null) { + sdkRow.put(target, RowsInnerValue.create("[PREDICT]")); + } + } + return sdkRow; + }) + .toList(); + + return PredictRequestPayload.create() + .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) + .rows(sdkRows) + .indexColumn(indexColumn); + } +} 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..c7f1a22 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -0,0 +1,447 @@ +/* + * © 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.aicore.api.AICoreService; +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.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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FioriRecommendationHandlerTest { + + private static CdsRuntime runtime; + private static PersistenceService db; + + @Mock(answer = Answers.CALLS_REAL_METHODS) + private AICoreService aiCoreService; + + private FioriRecommendationHandler cut; + private RecommendationClient predictionClient; + + @BeforeAll + static void bootRuntime() { + db = mock(PersistenceService.class); + when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + runtime = + CdsRuntimeConfigurer.create() + .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(aiCoreService, (service) -> predictionClient); + } + + // ── 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 = (rows, cols, idx) -> 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 = + (rows, cols, idx) -> + 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 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 (rows, predictionColumns, indexColumn) -> { + List predictions = new ArrayList<>(); + for (CdsData row : rows) { + if (predictionColumns.stream().noneMatch(col -> "[PREDICT]".equals(row.get(col)))) { + continue; + } + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + List available = + rows.stream() + .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) + .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(indexColumn, row.get(indexColumn)); + predictions.add(CdsData.create(prediction)); + } + return predictions; + }; + } + + private static RecommendationClient randomPickClient() { + Random random = new Random(42); + return (rows, predictionColumns, indexColumn) -> { + List predictions = new ArrayList<>(); + for (CdsData row : rows) { + Map prediction = new HashMap<>(); + boolean addPrediction = false; + for (String col : predictionColumns) { + if ("[PREDICT]".equals(row.get(col))) { + addPrediction = true; + List available = + rows.stream() + .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) + .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))); + } + } + if (addPrediction) { + prediction.put(indexColumn, row.get(indexColumn)); + predictions.add(CdsData.create(prediction)); + } + } + return predictions; + }; + } +} 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..a4173e3 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -0,0 +1,48 @@ +/* + * © 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.api.AICoreService; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +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 AICoreService aiCoreService; + + @Test + void aiCoreServiceFound_registersHandler() { + when(configurer.getCdsRuntime()).thenReturn(runtime); + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + .thenReturn(aiCoreService); + + 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(AICoreService.class, AICoreService.DEFAULT_NAME)) + .thenReturn(null); + + new RecommendationConfiguration().eventHandlers(configurer); + + verify(configurer, never()).eventHandler(any()); + } +} 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..bfcd611 --- /dev/null +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -0,0 +1,56 @@ +namespace test; + +@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; +} + +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; +} diff --git a/cds-starter-ai/pom.xml b/cds-starter-ai/pom.xml new file mode 100644 index 0000000..8214031 --- /dev/null +++ b/cds-starter-ai/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + + + com.sap.cds + cds-starter-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..e32cb96 --- /dev/null +++ b/coverage-report/pom.xml @@ -0,0 +1,218 @@ + + + 4.0.0 + + + com.sap.cds + cds-starter-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-ai-integration-tests-spring + test + + + + + + mtx-integration-tests + + + com.sap.cds + cds-feature-ai-integration-tests-mtx-local + 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 + + + + + + + + + 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 + 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 + + + + + check-ai-core + + check + + verify + + ${project.build.directory}/jacoco-merged.exec + + com/sap/cds/feature/aicore/**/* + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.00 + + + BRANCH + COVEREDRATIO + 0.00 + + + COMPLEXITY + COVEREDRATIO + 0.00 + + + + + + + + + + check-recommendations + + check + + verify + + ${project.build.directory}/jacoco-merged.exec + + com/sap/cds/feature/recommendation/**/* + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.00 + + + BRANCH + COVEREDRATIO + 0.00 + + + COMPLEXITY + COVEREDRATIO + 0.00 + + + + + + + + + + + + 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..ccad7b8 --- /dev/null +++ b/integration-tests/mtx-local/package.json @@ -0,0 +1,11 @@ +{ + "name": "mtx-local-integration-tests", + "version": "0.0.0", + "devDependencies": { + "@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..01a8d83 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java @@ -0,0 +1,78 @@ +/* + * © 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.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +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 { + AbstractAICoreService service = getService(); + + subscriptionEndpointClient.subscribeTenant(TENANT); + subscriptionEndpointClient.unsubscribeTenant(TENANT); + + assertThatCode(() -> subscriptionEndpointClient.unsubscribeTenant(TENANT)) + .doesNotThrowAnyException(); + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(TENANT); + } + + @Test + void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { + AbstractAICoreService service = getService(); + + for (int i = 0; i < 2; i++) { + subscriptionEndpointClient.subscribeTenant(TENANT); + assertThat(service.getTenantResourceGroupCache()).containsKey(TENANT); + + subscriptionEndpointClient.unsubscribeTenant(TENANT); + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(TENANT); + } + } + + private AbstractAICoreService getService() { + return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_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..a38467a --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java @@ -0,0 +1,101 @@ +/* + * © 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.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +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 { + AbstractAICoreService service = getService(); + + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + assertThat(service.isMultiTenancyEnabled()).isTrue(); + assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); + } + + @Test + void unsubscribeTenant_clearsCaches() throws Exception { + AbstractAICoreService service = getService(); + + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); + + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("tenant-3"); + } + + @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 AbstractAICoreService getService() { + return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_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..9b1c771 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java @@ -0,0 +1,103 @@ +/* + * © 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.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +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() { + AbstractAICoreService service = getService(); + assertThat(service.isMultiTenancyEnabled()).isTrue(); + } + + @Test + void differentTenants_getDifferentResourceGroups() throws Exception { + AbstractAICoreService service = getService(); + + subscriptionEndpointClient.subscribeTenant("tenant-1"); + subscriptionEndpointClient.subscribeTenant("tenant-2"); + + String rg1 = service.getTenantResourceGroupCache().get("tenant-1"); + String rg2 = service.getTenantResourceGroupCache().get("tenant-2"); + + assertThat(rg1).isNotNull(); + assertThat(rg2).isNotNull(); + assertThat(rg1).isNotEqualTo(rg2); + } + + @Test + void resourceGroupPrefix_applied() throws Exception { + AbstractAICoreService service = getService(); + + subscriptionEndpointClient.subscribeTenant("tenant-1"); + String rg = service.getTenantResourceGroupCache().get("tenant-1"); + + assertThat(rg).startsWith(service.getResourceGroupPrefix()); + } + + @Test + void clearTenantCache_onlyAffectsTarget() throws Exception { + AbstractAICoreService service = getService(); + + subscriptionEndpointClient.subscribeTenant("tenant-1"); + subscriptionEndpointClient.subscribeTenant("tenant-2"); + + String rg2 = service.getTenantResourceGroupCache().get("tenant-2"); + + service.clearTenantCache("tenant-1"); + + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("tenant-1"); + assertThat(service.getTenantResourceGroupCache()).containsEntry("tenant-2", rg2); + } + + private AbstractAICoreService getService() { + return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + } + + @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..3e27829 --- /dev/null +++ b/integration-tests/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + com.sap.cds + cds-starter-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..1843703 --- /dev/null +++ b/integration-tests/spring/pom.xml @@ -0,0 +1,151 @@ + + + 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.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + 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..73e07c4 --- /dev/null +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -0,0 +1,28 @@ +spring: + datasource: + url: "jdbc:h2:mem:testdb" + driver-class-name: org.h2.Driver + sql: + init: + mode: always + +cds: + 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..955364e --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java @@ -0,0 +1,92 @@ +/* + * © 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.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.recommendation.api.RptModelSpec; +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(AICoreService.class); + } + + @Test + void resourceGroupForTenant_singleTenancy_returnsDefault() { + AbstractAICoreService service = getAICoreServiceImpl(); + if (!service.isMultiTenancyEnabled()) { + String result = service.resourceGroupForTenant("any-tenant"); + assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + } + } + + @Test + void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { + AbstractAICoreService service = getAICoreServiceImpl(); + if (service.isMultiTenancyEnabled()) { + String tenantId = "itest-svc-tenant-" + System.currentTimeMillis(); + try { + String resourceGroupId = service.resourceGroupForTenant(tenantId); + assertThat(resourceGroupId).startsWith(service.getResourceGroupPrefix()); + assertThat(resourceGroupId).contains(tenantId); + + // Second call should return cached value + String cached = service.resourceGroupForTenant(tenantId); + assertThat(cached).isEqualTo(resourceGroupId); + } finally { + service.clearTenantCache(tenantId); + } + } + } + + @Test + void deploymentId_returnsDeploymentId() { + AbstractAICoreService service = getAICoreServiceImpl(); + String resourceGroup = service.getDefaultResourceGroup(); + + String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + assertThat(deploymentId).isNotNull().isNotBlank(); + + // Second call should use cache + String cached = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + assertThat(cached).isEqualTo(deploymentId); + } + + @Test + void clearTenantCache_removesEntries() { + AbstractAICoreService service = getAICoreServiceImpl(); + String tenantId = "itest-cache-tenant"; + String fakeRg = "fake-rg"; + String fakeKey = fakeRg + "::" + RptModelSpec.CONFIG_NAME; + service.getTenantResourceGroupCache().put(tenantId, fakeRg); + service.getResourceGroupDeploymentCache().put(fakeKey, "fake-deployment"); + + service.clearTenantCache(tenantId); + + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(tenantId); + assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKey(fakeKey); + } + + @Test + void configProperties_areApplied() { + AbstractAICoreService service = getAICoreServiceImpl(); + assertThat(service.getRetry()).isNotNull(); + assertThat(service.getDefaultResourceGroup()).isNotBlank(); + assertThat(service.getResourceGroupPrefix()).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..cc7e592 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java @@ -0,0 +1,133 @@ +/* + * © 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.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; +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.CqnService; +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(getAICoreCqnService(), getAICoreServiceImpl().getDefaultResourceGroup()); + } + + @Test + void resourceGroupForTenant_singleTenancy_returnsDefault() { + AbstractAICoreService service = getAICoreServiceImpl(); + assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); + String result = service.resourceGroupForTenant("any-tenant-id"); + assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + } + + @Test + void resourceGroupForTenant_multiTenancy_createsGroup() { + AbstractAICoreService service = getAICoreServiceImpl(); + assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantId = "itest-action-tenant-" + System.currentTimeMillis(); + try { + String resourceGroupId = service.resourceGroupForTenant(tenantId); + assertThat(resourceGroupId).startsWith(service.getResourceGroupPrefix()); + assertThat(resourceGroupId).contains(tenantId); + } finally { + service.clearTenantCache(tenantId); + } + } + + @Test + void deploymentId_returnsValidDeployment() { + AbstractAICoreService service = getAICoreServiceImpl(); + String resourceGroup = service.getDefaultResourceGroup(); + + String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + assertThat(deploymentId).isNotNull().isNotBlank(); + } + + @Test + void deploymentId_cachedOnSecondCall() { + AbstractAICoreService service = getAICoreServiceImpl(); + String resourceGroup = service.getDefaultResourceGroup(); + + String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + String second = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + 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() { + CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + + Result deployments = + service.run( + Select.from("AICore.deployments") + .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("AICore.deployments") + .where(d -> d.get("id").eq(targetId)) + .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); + + Result readResult = + service.run( + Select.from("AICore.deployments") + .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"); + } + + @Test + void resolveResourceGroupFromKeys_directKey() { + AbstractAICoreService service = getAICoreServiceImpl(); + Map keys = Map.of("resourceGroup_resourceGroupId", "my-rg"); + String resolved = service.resolveResourceGroupFromKeys(keys); + assertThat(resolved).isEqualTo("my-rg"); + } + + @Test + void resolveResourceGroupFromKeys_nestedMap() { + AbstractAICoreService service = getAICoreServiceImpl(); + Map keys = Map.of("resourceGroup", Map.of("resourceGroupId", "nested-rg")); + String resolved = service.resolveResourceGroupFromKeys(keys); + assertThat(resolved).isEqualTo("nested-rg"); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java new file mode 100644 index 0000000..c8f1296 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java @@ -0,0 +1,41 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +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 ApplicationServiceDelegationTest extends BaseIntegrationTest { + + @Test + @WithMockUser(username = "test-user") + void readConfigurations_viaApplicationService() throws Exception { + mockMvc + .perform(get("/odata/v4/TestService/Configurations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value").isArray()); + } + + @Test + @WithMockUser(username = "test-user") + void readDeployments_viaApplicationService() throws Exception { + mockMvc + .perform(get("/odata/v4/TestService/Deployments")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value").isArray()); + } + + @Test + @WithMockUser(username = "test-user") + void readResourceGroups_viaApplicationService() throws Exception { + mockMvc + .perform(get("/odata/v4/TestService/ResourceGroups")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value").isArray()); + } +} 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..b474950 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -0,0 +1,114 @@ +/* + * © 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.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.api.AICoreService; +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.CqnService; +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 AICoreService getAICoreService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + } + + protected AbstractAICoreService getAICoreServiceImpl() { + return (AbstractAICoreService) getAICoreService(); + } + + protected CqnService getAICoreCqnService() { + return (CqnService) getAICoreService(); + } + + protected String ensureRptDeploymentReady() { + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + return CACHED_DEPLOYMENT_IDS.computeIfAbsent( + resourceGroup, + rg -> { + ensureResourceGroupProvisioned(getAICoreCqnService(), rg); + return getAICoreService().deploymentId(rg, RptModelSpec.rpt1()); + }); + } + + protected void ensureResourceGroupProvisioned(CqnService service, String resourceGroup) { + if (!resourceGroupExists(service, resourceGroup)) { + logger.info("Creating resource group {} with itest owner label", resourceGroup); + service.run( + Insert.into("AICore.resourceGroups") + .entry( + Map.of( + "resourceGroupId", + resourceGroup, + "labels", + List.of(Map.of("key", ITEST_OWNER_LABEL_KEY, "value", resourceGroup))))); + } + waitForResourceGroupProvisioned(service, resourceGroup); + } + + private boolean resourceGroupExists(CqnService service, String resourceGroup) { + Result all = service.run(Select.from("AICore.resourceGroups")); + for (Row row : all) { + if (resourceGroup.equals(row.get("resourceGroupId"))) { + return true; + } + } + return false; + } + + private void waitForResourceGroupProvisioned(CqnService service, String resourceGroup) { + for (int i = 0; i < 30; i++) { + Result all = service.run(Select.from("AICore.resourceGroups")); + 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..ff45c9e --- /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.core.AbstractAICoreService; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.CqnService; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ConfigurationTest extends BaseIntegrationTest { + + @Test + void readAll_returnsConfigurations() { + CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + Result result = + service.run( + Select.from("AICore.configurations") + .where(c -> c.get("resourceGroup_resourceGroupId").eq(resourceGroup))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void readAll_filterByScenario() { + CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + Result result = + service.run( + Select.from("AICore.configurations") + .where( + c -> + c.get("scenarioId") + .eq("foundation-models") + .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void create_andReadById() { + CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + + String configName = "itest-config-" + System.currentTimeMillis(); + Result created = + service.run( + Insert.into("AICore.configurations") + .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("AICore.configurations") + .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() { + CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + + String configName = "itest-params-" + System.currentTimeMillis(); + Result created = + service.run( + Insert.into("AICore.configurations") + .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("AICore.configurations") + .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..77f5502 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java @@ -0,0 +1,103 @@ +/* + * © 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.core.AbstractAICoreService; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.CqnService; +import java.util.Map; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class DeploymentTest extends BaseIntegrationTest { + + @Test + void readAll_returnsDeployments() { + CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + Result result = + service.run( + Select.from("AICore.deployments") + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void readSingle_returnsDeploymentDetails() { + CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + Result all = + service.run( + Select.from("AICore.deployments") + .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("AICore.deployments") + .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() { + CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + + Result deployments = + service.run( + Select.from("AICore.deployments") + .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("AICore.deployments") + .where(d -> d.get("id").eq(targetId)) + .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); + + Result readResult = + service.run( + Select.from("AICore.deployments") + .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..c845f13 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java @@ -0,0 +1,98 @@ +/* + * © 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.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class MultiTenancyTest extends BaseIntegrationTest { + + private String tenantA; + private String tenantB; + + @AfterEach + void cleanup() { + AbstractAICoreService service = getAICoreServiceImpl(); + if (tenantA != null) { + service.clearTenantCache(tenantA); + tenantA = null; + } + if (tenantB != null) { + service.clearTenantCache(tenantB); + tenantB = null; + } + } + + @Test + void differentTenants_getDifferentResourceGroups() { + AbstractAICoreService service = getAICoreServiceImpl(); + assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); + tenantA = "itest-mt-a-" + System.currentTimeMillis(); + tenantB = "itest-mt-b-" + System.currentTimeMillis(); + + String rgA = service.resourceGroupForTenant(tenantA); + String rgB = service.resourceGroupForTenant(tenantB); + + assertThat(rgA).isNotEqualTo(rgB); + assertThat(rgA).contains(tenantA); + assertThat(rgB).contains(tenantB); + } + + @Test + void resourceGroupPrefix_appliedCorrectly() { + AbstractAICoreService service = getAICoreServiceImpl(); + assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); + tenantA = "itest-prefix-" + System.currentTimeMillis(); + + String rg = service.resourceGroupForTenant(tenantA); + assertThat(rg).startsWith(service.getResourceGroupPrefix()); + } + + @Test + void cacheIsolation_perTenant() { + AbstractAICoreService service = getAICoreServiceImpl(); + assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); + tenantA = "itest-cache-a-" + System.currentTimeMillis(); + tenantB = "itest-cache-b-" + System.currentTimeMillis(); + + String rgA = service.resourceGroupForTenant(tenantA); + String rgB = service.resourceGroupForTenant(tenantB); + + assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantA, rgA); + assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantB, rgB); + } + + @Test + void clearTenantCache_onlyAffectsTargetTenant() { + AbstractAICoreService service = getAICoreServiceImpl(); + assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); + tenantA = "itest-clear-a-" + System.currentTimeMillis(); + tenantB = "itest-clear-b-" + System.currentTimeMillis(); + + service.resourceGroupForTenant(tenantA); + String rgB = service.resourceGroupForTenant(tenantB); + + service.clearTenantCache(tenantA); + + assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(tenantA); + assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantB, rgB); + } + + @Test + void singleTenancy_alwaysReturnsDefault() { + AbstractAICoreService service = getAICoreServiceImpl(); + assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); + String rg1 = service.resourceGroupForTenant("tenant-x"); + String rg2 = service.resourceGroupForTenant("tenant-y"); + + assertThat(rg1).isEqualTo(service.getDefaultResourceGroup()); + assertThat(rg2).isEqualTo(service.getDefaultResourceGroup()); + } +} 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..2d32455 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java @@ -0,0 +1,150 @@ +/* + * © 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.ql.Delete; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.CqnService; +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 { + CqnService service = getAICoreCqnService(); + waitForResourceGroupProvisioned(service, createdResourceGroupId); + service.run( + Delete.from("AICore.resourceGroups") + .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); + } catch (Exception ignored) { + } + createdResourceGroupId = null; + } + } + + @Test + void create_andRead_resourceGroup() { + createdResourceGroupId = TEST_RG_PREFIX + System.currentTimeMillis(); + CqnService service = getAICoreCqnService(); + + service.run( + Insert.into("AICore.resourceGroups") + .entry(Map.of("resourceGroupId", createdResourceGroupId))); + + Result result = + service.run( + Select.from("AICore.resourceGroups") + .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; + CqnService service = getAICoreCqnService(); + + service.run( + Insert.into("AICore.resourceGroups") + .entry(Map.of("resourceGroupId", createdResourceGroupId, "tenantId", tenantId))); + + Result result = + service.run( + Select.from("AICore.resourceGroups").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() { + CqnService service = getAICoreCqnService(); + Result result = service.run(Select.from("AICore.resourceGroups")); + assertThat(result.list()).isNotNull(); + } + + @Test + void create_withLabels() { + createdResourceGroupId = TEST_RG_PREFIX + "labels-" + System.currentTimeMillis(); + CqnService service = getAICoreCqnService(); + + service.run( + Insert.into("AICore.resourceGroups") + .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("AICore.resourceGroups") + .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(); + CqnService service = getAICoreCqnService(); + + service.run(Insert.into("AICore.resourceGroups").entry(Map.of("resourceGroupId", rgId))); + + waitForResourceGroupProvisioned(service, rgId); + + assertThatCode(() -> + service.run(Delete.from("AICore.resourceGroups").where(r -> r.get("resourceGroupId").eq(rgId))) + ).doesNotThrowAnyException(); + + createdResourceGroupId = null; // already deleted + } + + private void waitForResourceGroupProvisioned(CqnService service, String rgId) + throws InterruptedException { + for (int i = 0; i < 30; i++) { + Result result = + service.run( + Select.from("AICore.resourceGroups").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/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..ecf43c6 --- /dev/null +++ b/integration-tests/spring/test-service.cds @@ -0,0 +1,41 @@ +using {itest} from '../db/schema'; +using { AICore } from 'com.sap.cds/ai'; + +service TestService { + entity Products as projection on itest.Products; + entity Configurations as projection on AICore.configurations; + entity Deployments as projection on AICore.deployments; + entity ResourceGroups as projection on AICore.resourceGroups; +} + +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..ac2ecb8 --- /dev/null +++ b/pom.xml @@ -0,0 +1,466 @@ + + + 4.0.0 + + com.sap.cds + cds-starter-ai-root + ${revision} + pom + + CDS Starter AI - Root + Aggregator for CAP Java AI plugins (recommendations, AI Core client) + + + 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-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 + + + + + + 1.0.0-SNAPSHOT + + + 17 + + 4.9.0 + + + 1.19.0 + + 3.4.5 + + 0.8.14 + + 6.1.0 + + 5.23.0 + + 3.5.0 + + 3.15.0 + + 3.4.1 + + 3.1.4 + + 3.12.0 + + 3.5.5 + + 3.5.5 + + 3.28.0 + + 3.6.3 + + 1.7.3 + + 5.30.0 + + 3.2.4 + + 4.9.8.3 + + + 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-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.5.1 + + + + + + + /* + * © $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 + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + + sign + + verify + + + + + org.sonatype.central + central-publishing-maven-plugin + 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..94e9eda --- /dev/null +++ b/samples/bookshop/.cdsrc.json @@ -0,0 +1,14 @@ +{ + "requires": { + "db": "hana", + "AICore": { + "model": "com.sap.cds/ai" + }, + "[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..ff2ac49 --- /dev/null +++ b/samples/bookshop/app/appconfig/fioriSandboxConfig.json @@ -0,0 +1,95 @@ +{ + "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" + } + } + ] + } + ] + } + } + }, + "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" + } + } + } + } + } + } + } +} 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..87e7b31 --- /dev/null +++ b/samples/bookshop/app/services.cds @@ -0,0 +1,6 @@ +/* + This model controls what gets served to Fiori frontends... +*/ +using from './common'; +using from './browse/fiori-service'; +using from './admin-books/fiori-service'; 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/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..9358397 --- /dev/null +++ b/samples/bookshop/db/schema.cds @@ -0,0 +1,39 @@ +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; +} 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..07631da --- /dev/null +++ b/samples/bookshop/package-lock.json @@ -0,0 +1,2806 @@ +{ + "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://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-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.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/samples/bookshop/package.json b/samples/bookshop/package.json new file mode 100644 index 0000000..60dd5df --- /dev/null +++ b/samples/bookshop/package.json @@ -0,0 +1,15 @@ +{ + "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" + } +} diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml new file mode 100644 index 0000000..cf076ff --- /dev/null +++ b/samples/bookshop/pom.xml @@ -0,0 +1,175 @@ + + + 4.0.0 + + customer + bookshop-parent + ${revision} + pom + + bookshop parent + + + srv + + + + + 1.0.0-SNAPSHOT + + + 17 + 4.9.0 + 3.5.6 + 1.0.0-SNAPSHOT + + + + + + + + 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-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..7124cc4 --- /dev/null +++ b/samples/bookshop/srv/ai-core-service.cds @@ -0,0 +1,30 @@ +using { AICore } from 'com.sap.cds/ai'; + +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/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..599800e --- /dev/null +++ b/samples/bookshop/srv/pom.xml @@ -0,0 +1,173 @@ + + + 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 + + + + 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..8023ca2 --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -0,0 +1,170 @@ +package customer.bookshop.handlers; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; +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.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 AICoreService getAICoreService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_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("AICore.configurations"))); + } + + @On(event = "setupTenantResources") + public void onSetupTenantResources(EventContext context) { + String rgId = getAICoreService().resourceGroup(); + context.put("result", rgId); + context.setCompleted(); + } + + @On(event = "getMyResourceGroup") + public void onGetMyResourceGroup(EventContext context) { + String rgId = getAICoreService().resourceGroup(); + context.put("result", rgId); + context.setCompleted(); + } + + @On(event = "provisionRpt1") + public void onProvisionRpt1(EventContext context) { + String resourceGroupId = (String) context.get("resourceGroupId"); + String deploymentId = getAICoreService().deploymentId(resourceGroupId, RptModelSpec.rpt1()); + 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("AICore.deployments") + .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("AICore.configurations") + .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 rows = new ArrayList<>(); + rows.add( + CdsData.create( + Map.of("ID", "ctx-1", "name", "Laptop", "price", "999.99", "category", "Electronics"))); + rows.add( + CdsData.create( + Map.of("ID", "ctx-2", "name", "Mouse", "price", "29.99", "category", "Electronics"))); + rows.add( + CdsData.create( + Map.of("ID", "ctx-3", "name", "Shirt", "price", "49.99", "category", "Clothing"))); + rows.add( + CdsData.create( + Map.of("ID", "ctx-4", "name", "Novel", "price", "14.99", "category", "Books"))); + rows.add( + CdsData.create( + Map.of("ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); + + for (Map product : products) { + Map row = new HashMap<>(product); + row.put("category", "[PREDICT]"); + rows.add(CdsData.create(row)); + } + + AICoreService service = getAICoreService(); + String rg = service.resourceGroup(); + String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); + RptInferenceClient client = + new RptInferenceClient( + service.inferenceClient(rg, deploymentId), + ((AbstractAICoreService) service).getRetry()); + List predictions = client.predict(rows, List.of("category"), "ID"); + + List> results = new ArrayList<>(); + 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(); + } +} 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/resources/application.yaml b/samples/bookshop/srv/src/main/resources/application.yaml new file mode 100644 index 0000000..bbd2210 --- /dev/null +++ b/samples/bookshop/srv/src/main/resources/application.yaml @@ -0,0 +1,48 @@ +logging: + level: + root: INFO + com.sap.cds.feature.aicore.core: 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 +--- +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/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/**" + ] + } +}