From 0b5430ab963ec7c80fbe25ec1458d3aaff4d1d77 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Mon, 4 May 2026 16:00:52 +0200 Subject: [PATCH 01/42] Add workflows for main, prs to main and scan with blackduck and sonar action --- .github/actions/build/action.yml | 40 ++++ .../actions/scan-with-blackduck/action.yml | 63 +++++++ .github/actions/scan-with-sonar/action.yml | 75 ++++++++ .github/workflows/main.yml | 32 ++++ .github/workflows/pipeline.yml | 175 ++++++++++++++++++ .github/workflows/pr.yml | 31 ++++ 6 files changed, 416 insertions(+) create mode 100644 .github/actions/build/action.yml create mode 100644 .github/actions/scan-with-blackduck/action.yml create mode 100644 .github/actions/scan-with-sonar/action.yml create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/pipeline.yml create mode 100644 .github/workflows/pr.yml diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml new file mode 100644 index 0000000..26dd1b9 --- /dev/null +++ b/.github/actions/build/action.yml @@ -0,0 +1,40 @@ +name: Maven Build +description: Builds a Maven project. + +inputs: + java-version: + description: The Java version the build will run with. + required: true + maven-version: + description: The Maven version the build will run with. + required: true + mutation-testing: + description: Whether to run mutation testing or not. + default: 'true' + required: false + +runs: + using: composite + steps: + - name: Set up Java ${{ inputs.java-version }} + uses: actions/setup-java@v5 + with: + java-version: ${{ inputs.java-version }} + distribution: sapmachine + cache: maven + + - name: Set up Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Piper Maven build + uses: SAP/project-piper-action@main + with: + step-name: mavenBuild + docker-image: '' + + - name: Mutation Testing + if: ${{ inputs.mutation-testing == 'true' }} + run: mvn org.pitest:pitest-maven:mutationCoverage -f cds-feature-ai/pom.xml -ntp -B + shell: bash diff --git a/.github/actions/scan-with-blackduck/action.yml b/.github/actions/scan-with-blackduck/action.yml new file mode 100644 index 0000000..7ac1a58 --- /dev/null +++ b/.github/actions/scan-with-blackduck/action.yml @@ -0,0 +1,63 @@ +name: Scan with BlackDuck +description: Scans the project with BlackDuck + +inputs: + blackduck_token: + description: The token to use for BlackDuck authentication + required: true + github_token: + description: The token to use for GitHub authentication + required: true + java-version: + description: The version of Java to use + default: '17' + required: false + maven-version: + description: The Maven version the build shall run with. + required: true + scan_mode: + description: The scan mode to use (FULL or RAPID) + default: 'FULL' + required: false + +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: BlackDuck Detect Scan + run: | + bash <(curl -s -L https://detect.synopsys.com/detect9.sh) \ + --blackduck.url=https://sap.blackducksoftware.com/ \ + --blackduck.api.token="${BLACKDUCK_TOKEN}" \ + --detect.project.name=com.sap.cds.feature.ai \ + --detect.project.version.name="${REVISION}" \ + --detect.included.detector.types=MAVEN \ + --detect.excluded.directories='**/node_modules,**/*test*,**/localrepo,**/target/site,**/*-site.jar,**/samples/**' \ + --detect.maven.excluded.modules=integration-tests,integration-tests/db,integration-tests/generic,integration-tests/mtx-local/srv \ + --detect.maven.build.command='-pl com.sap.cds:cds-feature-ai' \ + --detect.tools=DETECTOR,BINARY_SCAN \ + --detect.blackduck.scan.mode="${SCAN_MODE}" \ + --detect.risk.report.pdf=false \ + --logging.level.detect=INFO + shell: bash + env: + BLACKDUCK_TOKEN: ${{ inputs.blackduck_token }} + SCAN_MODE: ${{ inputs.scan_mode }} + REVISION: ${{ steps.get-revision.outputs.REVISION }} diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml new file mode 100644 index 0000000..22df515 --- /dev/null +++ b/.github/actions/scan-with-sonar/action.yml @@ -0,0 +1,75 @@ +name: Scan with SonarQube +description: Scans the project with SonarQube + +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@v5 + with: + java-version: ${{inputs.java-version}} + distribution: sapmachine + cache: maven + + - name: Set up Maven ${{inputs.maven-version}} + uses: stCarolas/setup-maven@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: Print Revision + run: echo "${{steps.get-revision.outputs.REVISION}}" + shell: bash + + - name: Build project for SonarQube scan + run: | + mvn clean verify -ntp -B + shell: bash + + - name: Verify JaCoCo reports exist + run: | + echo "=== Checking JaCoCo reports ===" + find . -name "jacoco.xml" -type f + if [ -f "cds-feature-ai/target/site/jacoco/jacoco.xml" ]; then + echo "Found: cds-feature-ai/target/site/jacoco/jacoco.xml" + else + echo "Missing: cds-feature-ai/target/site/jacoco/jacoco.xml" + fi + 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 + uses: SAP/project-piper-action@main + with: + step-name: sonarExecuteScan + flags: > + --token=${{ inputs.sonarq-token }} + --githubToken=${{ inputs.github-token }} + --version=${{ steps.get-revision.outputs.REVISION }} + --inferJavaBinaries=true + --options=-Dsonar.exclusions=**/samples/**,-Dsonar.coverage.jacoco.xmlReportPaths=coverage-report/target/site/jacoco-aggregate/jacoco.xml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..6ba3af4 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,32 @@ +name: CI - MAIN + +permissions: + contents: read + actions: read + security-events: write + packages: read + +env: + MAVEN_VERSION: '3.9.12' + +on: + workflow_dispatch: + push: + branches: [main] + +jobs: + blackduck: + name: Blackduck Scan + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Scan With Black Duck + uses: ./.github/actions/scan-with-blackduck + with: + blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + maven-version: ${{ env.MAVEN_VERSION }} + scan_mode: RAPID diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..5abb402 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,175 @@ +name: Reusable Workflow + +env: + MAVEN_VERSION: '3.9.12' + +on: + workflow_call: + inputs: + deploy-snapshot: + required: true + type: boolean + default: false + +jobs: + build: + name: Build (Java ${{ matrix.java-version }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + matrix: + java-version: [ 17, 21 ] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # For internal PRs (same repo), checkout PR head to test actual changes + # For external PRs (forks), checkout base branch for security + ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} + + - name: Spotless check + run: mvn spotless:check -Dspotless.check.skip=false + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ matrix.java-version }} + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: build-artifacts-java-${{ matrix.java-version }} + path: | + **/target/*.jar + **/pom.xml + .mvn/ + retention-days: 1 + + integration-tests: + name: Integration Tests (Java ${{ matrix.java-version }}, ${{ matrix.test-type }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: build + #env: + # cf login for tests against aicore + strategy: + fail-fast: false + matrix: + java-version: [ 17, 21 ] + test-type: [ build-version, latest-version, oss ] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} + + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + name: build-artifacts-java-${{ matrix.java-version }} + + sonarqube-scan: + name: SonarQube Scan + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: build + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} + - 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.GH_TOKEN }} + + codeql: + name: CodeQL Analysis + runs-on: ubuntu-latest + needs: build + timeout-minutes: 30 + permissions: + security-events: write + packages: read + actions: read + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Java + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'sapmachine' + cache: 'maven' + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: java-kotlin + build-mode: manual + + - name: Build Java code + run: mvn clean compile -DskipTests -B -ntp + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:java-kotlin" + + deploy-snapshot: + name: Deploy snapshot to Artifactory + runs-on: ubuntu-latest + timeout-minutes: 30 + if: ${{ inputs.deploy-snapshot == true }} + needs: [build, integration-tests, codeql] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Java + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'sapmachine' + cache: 'maven' + server-id: artifactory + server-username: DEPLOYMENT_USER + server-password: DEPLOYMENT_PASS + + - name: Set up Maven ${{ env.MAVEN_VERSION }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Set Dry Run for Pull Request + if: github.event_name == 'pull_request_target' + run: echo "DRY_RUN_PARAM=-DaltDeploymentRepository=local-repo::default::file:./local-repo" >> $GITHUB_ENV + shell: bash + + - name: Get Revision + id: get-revision + run: | + echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT + shell: bash + + - name: Print Revision + run: echo "Current revision ${{ steps.get-revision.outputs.REVISION }}" + shell: bash + + - name: Deploy snapshot + if: ${{ endsWith(steps.get-revision.outputs.REVISION, '-SNAPSHOT') }} + run: mvn -B -ntp -fae -pl !integration-tests,!integration-tests/db,!integration-tests/generic,!integration-tests/mtx-local/srv -Dmaven.install.skip=true -Dmaven.test.skip=true -DdeployAtEnd=true deploy + env: + DEPLOYMENT_USER: ${{ secrets.DEPLOYMENT_USER }} + DEPLOYMENT_PASS: ${{ secrets.DEPLOYMENT_PASS }} + shell: bash diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..c1ee2a1 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,31 @@ +name: CI - PR + +on: + workflow_dispatch: + pull_request_target: + branches: [main] + types: [reopened, synchronize, opened] + +permissions: + contents: read + actions: read + security-events: write + packages: read + +jobs: + requires-approval: + runs-on: ubuntu-latest + name: "Waiting for PR approval as this workflow runs on pull_request_target" + if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.owner.login != 'cap-java' + environment: pr-approval + steps: + - name: Approval Step + run: echo "This job has been approved!" + + build-and-test: + needs: requires-approval + if: always() && (needs.requires-approval.result == 'success' || needs.requires-approval.result == 'skipped') + uses: ./.github/workflows/pipeline.yml + with: + deploy-snapshot: false + secrets: inherit From 4f2a43b7c3d226be4ec1d87e8118f9979522ff0a Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 24 Mar 2026 09:56:03 +0100 Subject: [PATCH 02/42] Add README.md --- README.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/README.md b/README.md index e69de29..c99683d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,168 @@ +# SAP Cloud Application Programming Model, AI plugin for Java + +This is the Java version of the [SAP CAP AI plugin for Node.js](https://github.com/cap-js/ai). + +## About this project + +The SAP Cloud Application Programming Model, AI plugin for Java provides AI-powered UI recommendations for CAP Java applications, leveraging SAP AI Core and the SAP-RPT-1 model. + +> [!IMPORTANT] +> In multi-tenancy scenarios with a sidecar, the plugin must be included in the sidecar for SAP AI Core handling. + +### 1. Use case: Recommendations + +Recommendations are implemented leveraging SAP-RPT-1 and AI Core. This plugin generically hooks into any entity which has properties with a value help (detected via `@Common.ValueList` on the property or `@cds.odata.valuelist` on the association target). + +```cds +entity Books { + key ID : Integer; + title : String(111); + descr : String(1111); + genre : Association to one Genres; + status : Association to one Status; +} +annotate Genres with @cds.odata.valuelist; +annotate Books with { + status @Common.ValueList : { + CollectionPath : 'Status', + Parameters: [ + { + $Type: 'Common.ValueListParameterInOut' + ValueListProperty : 'code', + LocalDataProperty : status_code + } + ] + } +} +``` + +The annotated fields automatically receive AI-powered recommendations in Fiori draft edit mode. The handler fetches existing rows from the database as training context, calls the RPT-1 model, and writes the predictions into the `SAP_Recommendations` property on the result. + +If you do not want recommendations for a specific field, annotate it with `@UI.RecommendationState: 0`. + +```cds +annotate Books with { + genre @UI.RecommendationState : 0; +} +``` + +### 2. Use case: Simplified AI Core usage (supported from version 0.0.2) + +The plugin introduces an `AICore` CAP service that automatically performs some administrative tasks and offers simplified access to AI Core. + +#### Automatic operations + +- The plugin automatically creates a new SAP AI Core resource group per tenant during tenant onboarding and deletes it during offboarding. +- The plugin automatically creates an RPT-1 deployment per resource group for the recommendations feature. + +## Requirements and Setup + +### Prerequisites + +- Java 17+ +- Maven 3.6.3+ +- Node.js 20+ (for CDS build tooling) +- `@sap/cds-dk` 9+ (CDS build tooling) +- An [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding (for production) + +### Dependencies to add + +pom.xml: +```xml + + com.sap.cds + cds-feature-ai + ${revision} + +``` + +package.json: +```json +"dependencies": { + "@cap-js/ai": "" +} +``` + +### AI Core service binding + +To use the plugin in production scenarios you need an [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding. +The plugin will automatically create resource groups per tenant labeled with `ext.ai.sap.com/CDS_TENANT_ID` in multi-tenancy scenarios and create an RPT-1 deployment in each for the recommendations feature. For multitenancy, set `cds.multitenancy.enabled=true` (or the environment variable `CDS_MULTITENANCY_ENABLED=true`). + +In single-tenant setups the plugin uses the 'default' resource group and creates an RPT-1 deployment as well if none exists. + +For single-tenant deployments you can change the resource group as follow in the `package.json`: + +```json +{ + "cds": { + "requires": { + "AICore": { + "resourceGroup": "CUSTOM_SINGLE_TENANT_RESOURCE_GROUP" + } + } + } +} +``` + +or + +```yaml +# application.yaml +cds: + requires: + AICore: + resourceGroup: CUSTOM_RESOURCE_GROUP +``` + +For Cloud Foundry apps an example config could look like in [samples/bookshop/mta.yaml](samples/bookshop/mta.yaml). + +For local development without an AI Core binding, the plugin falls back to a `MockAIClient` that returns random predictions from the existing context rows. + + +## Test the plugin locally + +In `samples/bookshop` you can find a complete CAP Java bookshop that demonstrates the plugin: + +```bash +mvn clean install +cd samples/bookshop +mvn spring-boot:run +``` + +### Local Testing +To execute local tests, simply run: + +```bash +mvn test +``` + +or for a full build including tests: + +```bash +mvn clean install +``` + +To run integration tests against a real AI Core instance, set the following environment variable, then run `mvn test`: + +| Variable | Description | +|---|---| +| `AICORE_SERVICE_KEY` | Full AI Core service key JSON: `{ "clientid": "...", "clientsecret": "...", "url": "...", "serviceurls": { "AI_API_URL": "..." } }` | + +If the variable is not set, the integration tests are skipped automatically. + +## Support, Feedback, Contributing + +This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-java/cds-feature-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-feature-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-feature-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. + From d0ad47d395ea414e77b9c8d487c951c242e0aa6d Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Mon, 27 Apr 2026 10:57:58 +0200 Subject: [PATCH 03/42] Initial version --- .DS_Store | Bin 0 -> 8196 bytes .cdsprettier.json | 8 + .gitignore | 31 ++ CHANGELOG.md | 15 + CONTRIBUTING.md | 38 ++ LICENSES/Apache-2.0.txt | 73 ++++ REUSE.toml | 23 ++ gh_ruleset.json | 59 +++ package.json | 16 + pom.xml | 223 +++++++++++ samples/.DS_Store | Bin 0 -> 6148 bytes samples/bookshop/.cdsrc.json | 2 + samples/bookshop/.gitignore | 34 ++ samples/bookshop/app/_i18n/i18n.properties | 15 + samples/bookshop/app/_i18n/i18n_de.properties | 15 + .../app/admin-books/fiori-service.cds | 79 ++++ .../app/admin-books/webapp/Component.js | 8 + .../admin-books/webapp/i18n/i18n.properties | 3 + .../webapp/i18n/i18n_de.properties | 3 + .../app/admin-books/webapp/manifest.json | 145 +++++++ .../app/appconfig/fioriSandboxConfig.json | 95 +++++ samples/bookshop/app/browse/fiori-service.cds | 58 +++ .../bookshop/app/browse/webapp/Component.js | 7 + .../app/browse/webapp/i18n/i18n.properties | 3 + .../app/browse/webapp/i18n/i18n_de.properties | 3 + .../bookshop/app/browse/webapp/manifest.json | 137 +++++++ samples/bookshop/app/common.cds | 263 +++++++++++++ samples/bookshop/app/index.html | 32 ++ samples/bookshop/app/services.cds | 6 + .../db/data/sap-common-Currencies.csv | 204 ++++++++++ .../db/data/sap.capire.bookshop-Authors.csv | 5 + .../db/data/sap.capire.bookshop-Books.csv | 10 + .../db/data/sap.capire.bookshop-Genres.csv | 17 + samples/bookshop/db/schema.cds | 39 ++ samples/bookshop/package.json | 14 + samples/bookshop/pom.xml | 152 ++++++++ samples/bookshop/srv/admin-service.cds | 5 + samples/bookshop/srv/cat-service.cds | 34 ++ samples/bookshop/srv/pom.xml | 159 ++++++++ .../java/customer/bookshop/Application.java | 13 + .../handlers/CatalogServiceHandler.java | 63 +++ .../srv/src/main/resources/application.yaml | 28 ++ .../customer/bookshop/ApplicationTest.java | 21 + .../handlers/CatalogServiceHandlerTest.java | 42 ++ srv/pom.xml | 202 ++++++++++ .../feature/ai/AIRuntimeConfiguration.java | 51 +++ .../com/sap/cds/feature/ai/Application.java | 15 + .../ai/FioriRecommendationHandler.java | 332 ++++++++++++++++ .../sap/cds/feature/ai/client/AIClient.java | 20 + .../cds/feature/ai/client/AICoreClient.java | 174 +++++++++ .../cds/feature/ai/client/MockAIClient.java | 56 +++ .../feature/ai/client/setup/AICoreSetup.java | 365 ++++++++++++++++++ ...s.services.runtime.CdsRuntimeConfiguration | 1 + srv/src/main/resources/application.yaml | 7 + .../ai/FioriRecommendationHandlerTest.java | 299 ++++++++++++++ .../ai/client/AICoreClientPredictionTest.java | 229 +++++++++++ .../feature/ai/client/MockAIClientTest.java | 146 +++++++ .../ai/client/setup/AICoreSetupTest.java | 206 ++++++++++ 58 files changed, 4303 insertions(+) create mode 100644 .DS_Store create mode 100644 .cdsprettier.json create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 REUSE.toml create mode 100644 gh_ruleset.json create mode 100644 package.json create mode 100644 pom.xml create mode 100644 samples/.DS_Store create mode 100644 samples/bookshop/.cdsrc.json create mode 100644 samples/bookshop/.gitignore create mode 100644 samples/bookshop/app/_i18n/i18n.properties create mode 100644 samples/bookshop/app/_i18n/i18n_de.properties create mode 100644 samples/bookshop/app/admin-books/fiori-service.cds create mode 100644 samples/bookshop/app/admin-books/webapp/Component.js create mode 100644 samples/bookshop/app/admin-books/webapp/i18n/i18n.properties create mode 100644 samples/bookshop/app/admin-books/webapp/i18n/i18n_de.properties create mode 100644 samples/bookshop/app/admin-books/webapp/manifest.json create mode 100644 samples/bookshop/app/appconfig/fioriSandboxConfig.json create mode 100644 samples/bookshop/app/browse/fiori-service.cds create mode 100644 samples/bookshop/app/browse/webapp/Component.js create mode 100644 samples/bookshop/app/browse/webapp/i18n/i18n.properties create mode 100644 samples/bookshop/app/browse/webapp/i18n/i18n_de.properties create mode 100644 samples/bookshop/app/browse/webapp/manifest.json create mode 100644 samples/bookshop/app/common.cds create mode 100644 samples/bookshop/app/index.html create mode 100644 samples/bookshop/app/services.cds create mode 100644 samples/bookshop/db/data/sap-common-Currencies.csv create mode 100644 samples/bookshop/db/data/sap.capire.bookshop-Authors.csv create mode 100644 samples/bookshop/db/data/sap.capire.bookshop-Books.csv create mode 100644 samples/bookshop/db/data/sap.capire.bookshop-Genres.csv create mode 100644 samples/bookshop/db/schema.cds create mode 100644 samples/bookshop/package.json create mode 100644 samples/bookshop/pom.xml create mode 100644 samples/bookshop/srv/admin-service.cds create mode 100644 samples/bookshop/srv/cat-service.cds create mode 100644 samples/bookshop/srv/pom.xml create mode 100644 samples/bookshop/srv/src/main/java/customer/bookshop/Application.java create mode 100644 samples/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java create mode 100644 samples/bookshop/srv/src/main/resources/application.yaml create mode 100644 samples/bookshop/srv/src/test/java/customer/bookshop/ApplicationTest.java create mode 100644 samples/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java create mode 100644 srv/pom.xml create mode 100644 srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java create mode 100644 srv/src/main/java/com/sap/cds/feature/ai/Application.java create mode 100644 srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java create mode 100644 srv/src/main/java/com/sap/cds/feature/ai/client/AIClient.java create mode 100644 srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java create mode 100644 srv/src/main/java/com/sap/cds/feature/ai/client/MockAIClient.java create mode 100644 srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java create mode 100644 srv/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration create mode 100644 srv/src/main/resources/application.yaml create mode 100644 srv/src/test/java/com/sap/cds/feature/ai/FioriRecommendationHandlerTest.java create mode 100644 srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java create mode 100644 srv/src/test/java/com/sap/cds/feature/ai/client/MockAIClientTest.java create mode 100644 srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..db2fb2d3abd3d847795c3ee82d3d7ddd14b5d2b4 GIT binary patch literal 8196 zcmeHMTWl0n7(U;$&>1GcPzw}f+^s7VOARd+5K&mRZRFB&+m>Fqtg|}EcpTsk%ntdVWnQQfZ`;B*>pJhgr`Eg+~CA z{0I;hYSTF&^of^bvLwfZ3|(nXae6?>6{RBvggfcu+?-^xB*%ml?hL}6p>$`IPACZP zPJZ#woFOS>R7V+zGB7m*oIX{|X9gQ&Lsy^Qy)0?YWQQEv51N|JL#n8pF|$gpl2v(s zdcYe_2S!lvQ$~I-?{#^elP$N6>>k@1&S*8Qo*NjpYvn@++qB4JUytpY-f+q*xTYWa zriDUQWF@05ICgAxQ%hsKd9*PuTI*WYj*cqw{KU%jyEBKKLDxGj!a!*$V6!KM_4w47 z9KYC_b$Cn_>2gdL$}uUkRCTuA)7#g-cV9-+3cj`5a*V((T5U#P`5A4lPa$r}dG5}< zRiw~*a-LHdbV)NWXE?b68C#niCv86@taEwKadvvX9oU}R*Be+z1Fqu}O;Gjr`(8e1 z4JYfhX?|f<&F`^eh&%gxth`TBQBKWS7`tQnU8~mKmu%nCx$E3qZC6v^+Gt}sHdc7>~<>Re+i)t~fHt3CtX2@MWg)F96sWvN` zuXG)zS1(*!)HSkZD&3BewHz_DPF*i++mxQ7h*%8Ps2gRaH|GrwQZT+4OX+R0_N9D) z-?+o@gB?SbTYjXJzFC(0{Q)Cy?HDHDl|4FSIed*Y&RyC~JMi*hHn!L4dLN%^lWAHe z1--a-QMI1dyJhVx`DnIVvX;~WP@lXKDJ&9TVak9$tE#eBgA;kw>X_O zu@u|N4iMQ!*a`M5dzHP%PO~%YGxiNT&wgOPuwU74>@vH;{zN5ap$1E^6!ln+JFyCP zV>Q;G6&>ircJw0$69;h!4hksZC`Rxw9>Z}wffINJFW^PIj8||HZ{cmcgOBhDKE>zw z0_X7~F5)L#!ev~+ACe+fOG_kOx=pH=mP-xN3TcD1QQ9o+lKQ2LG$3M-%7oiL9#hzm zMo3IrM2K+o5s(v3I(2Qr#U9waW$U(%8L+j|M16>i$%qwPRAb#l3+w1~l+>y?edTl9y6$#0rp+MYi0-m_>dqw6i0+EU zSWJ_MtD@V~98n?0ZGDl8`=rqJFy#SbYVX-Fpwp*J8&_CBgo?zM)3&7@F-#Z zNj!z8@f@BfyuU<1`oo>=zg?R2-e*SNt{`>z;8eR0*Q3j$6Tu%m2*_Ligk>zx&n?$-eT@TX53%45+ wGIXJeoAB}QI8Ji>4?}9lOS)u9jtNN`D*yV2fb0Fi9qs?o{x1&1|H=LTZ(9m=C;$Ke literal 0 HcmV?d00001 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/.gitignore b/.gitignore new file mode 100644 index 0000000..a057d33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +**/gen/ +**/edmx/ +*.db +*.sqlite +*.sqlite-wal +*.sqlite-shm +schema*.sql +default-env.json + +**/bin/ +**/target/ +.flattened-pom.xml +.classpath +.project +.settings + +**/node/ +**/node_modules/ +**/package-lock.json +**/.mta/ +*.mtar + +*.log* +gc_history* +hs_err* +*.tgz +*.iml + +.vscode +.idea +.reloadtrigger 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..017fbe5 --- /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 contacting [a project maintainer](.reuse/dep5). + +## 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/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/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..7501d58 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,23 @@ +version = 1 +SPDX-PackageName = "" +SPDX-PackageSupplier = "" +SPDX-PackageDownloadLocation = "" +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 = " SAP SE or an SAP affiliate company and contributors" +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = "" +precedence = "aggregate" +SPDX-FileCopyrightText = "" +SPDX-License-Identifier = "" + +[[annotations]] +path = "" +precedence = "aggregate" +SPDX-FileCopyrightText = "" +SPDX-License-Identifier = "" 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/package.json b/package.json new file mode 100644 index 0000000..b899176 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "cds-feature-ai-java", + "version": "1.0.0-SNAPSHOT", + "description": "CAP Java AI Features Plugin", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "lint": "cds lint", + "prettier:check": "cds format --check" + }, + "devDependencies": { + "@sap/cds-dk": "^9.3.2", + "@sap/eslint-plugin-cds": "^4", + "eslint": "^10" + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..5eee0cf --- /dev/null +++ b/pom.xml @@ -0,0 +1,223 @@ + + + 4.0.0 + + com.sap.cds + cds-feature-ai-parent + ${revision} + pom + + cds-feature-ai parent + + + srv + + + + + 1.0.0-SNAPSHOT + + + 17 + 4.4.1 + 3.5.6 + 1.18.0 + + https://nodejs.org/dist/ + UTF-8 + + com/sap/cds/feature/ai/generated/ + + + + + + + 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-maven-plugin + ${cds.services.version} + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + org.pitest + pitest-maven + 1.23.0 + + + + + + + + maven-compiler-plugin + 3.14.1 + + ${jdk.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 + + + ${jdk.version} + + + + true + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.4.0 + + + + + + + /* + * © $YEAR SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ + + + + + pom.xml + + + + + + + + check + + process-sources + + + + + + + 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/samples/.DS_Store b/samples/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0Book}', + 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/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/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/package.json b/samples/bookshop/package.json new file mode 100644 index 0000000..be60784 --- /dev/null +++ b/samples/bookshop/package.json @@ -0,0 +1,14 @@ +{ + "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": "*", + "@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..2f39ab7 --- /dev/null +++ b/samples/bookshop/pom.xml @@ -0,0 +1,152 @@ + + + 4.0.0 + + customer + bookshop-parent + ${revision} + pom + + bookshop parent + + + + 1.0.0-SNAPSHOT + + + 17 + 4.4.0 + 3.5.6 + + https://nodejs.org/dist/ + UTF-8 + + + + srv + + + + + + + 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-feature-ai + 1.0.0-SNAPSHOT + + + + + + + + + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + + + + + + maven-compiler-plugin + 3.14.1 + + ${jdk.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 + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + + maven-enforcer-plugin + 3.6.2 + + + Project Structure Checks + + enforce + + + + + 3.6.3 + + + ${jdk.version} + + + + true + + + + + + + diff --git a/samples/bookshop/srv/admin-service.cds b/samples/bookshop/srv/admin-service.cds new file mode 100644 index 0000000..a9f5e36 --- /dev/null +++ b/samples/bookshop/srv/admin-service.cds @@ -0,0 +1,5 @@ +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; +} 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..2b9cc82 --- /dev/null +++ b/samples/bookshop/srv/pom.xml @@ -0,0 +1,159 @@ + + + 4.0.0 + + + bookshop-parent + customer + ${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-feature-ai + + + + org.springframework.security + spring-security-test + test + + + + + ${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 + + + + + + + + \ No newline at end of file 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..f395d21 --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/Application.java @@ -0,0 +1,13 @@ +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/CatalogServiceHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java new file mode 100644 index 0000000..72f17a6 --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java @@ -0,0 +1,63 @@ +package customer.bookshop.handlers; + +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +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 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; + +@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..3420efb --- /dev/null +++ b/samples/bookshop/srv/src/main/resources/application.yaml @@ -0,0 +1,28 @@ + +logging: + level: + root: INFO + com.sap.cds: DEBUG +--- +spring: + datasource: + url: "jdbc:h2:mem:testdb" + config: + activate: + on-profile: default + sql: + init: + platform: h2 +cds: + security: + mock: + users: + admin: + password: admin + roles: + - admin + user: + password: user + data-source: + auto-config: + enabled: false 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..152543a --- /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(); + } +} \ No newline at end of file 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..6c510cd --- /dev/null +++ b/samples/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java @@ -0,0 +1,42 @@ +package customer.bookshop.handlers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import cds.gen.catalogservice.Books; + +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/srv/pom.xml b/srv/pom.xml new file mode 100644 index 0000000..9c73f7e --- /dev/null +++ b/srv/pom.xml @@ -0,0 +1,202 @@ + + + 4.0.0 + + + com.sap.cds + cds-feature-ai-parent + ${revision} + + + cds-feature-ai + jar + + cds-feature-ai + + + + + + com.sap.cds + cds-starter-spring-boot + + + + com.sap.cds + cds-services-utils + + + + org.slf4j + slf4j-api + + + + + com.sap.ai.sdk.foundationmodels + sap-rpt + ${ai-sdk.version} + + + + com.sap.ai.sdk + core + ${ai-sdk.version} + + + + 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.security + spring-security-test + test + + + + + ${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 + + + + + + + + org.pitest + pitest-maven + + + com.sap.cds.feature.ai.* + + + CONSTRUCTOR_CALLS + VOID_METHOD_CALLS + NON_VOID_METHOD_CALLS + REMOVE_CONDITIONALS_ORDER_ELSE + CONDITIONALS_BOUNDARY + EMPTY_RETURNS + NEGATE_CONDITIONALS + REMOVE_CONDITIONALS_EQUAL_IF + REMOVE_CONDITIONALS_EQUAL_ELSE + REMOVE_CONDITIONALS_ORDER_IF + + 80 + 70 + + + + org.pitest + pitest-junit5-plugin + 1.2.3 + + + + + + + diff --git a/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java b/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java new file mode 100644 index 0000000..f0da8a6 --- /dev/null +++ b/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java @@ -0,0 +1,51 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai; + +import com.sap.cds.feature.ai.client.setup.AICoreSetup; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AIRuntimeConfiguration implements CdsRuntimeConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(AIRuntimeConfiguration.class); + + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + + CdsRuntime runtime = configurer.getCdsRuntime(); + ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); + + PersistenceService persistenceService = + serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + logger.info("PersistenceService obtained from ServiceCatalog: " + (persistenceService != null)); + + Optional bindingOpt = getAIBinding(configurer.getCdsRuntime().getEnvironment()); + // If the AI Core service binding is present, create the AICoreSetup event handler to manage + // resource groups for tenants. + Optional setupOpt = bindingOpt.map(b -> new AICoreSetup()); + setupOpt.ifPresent( + setup -> { + configurer.eventHandler(setup); + logger.info("Registered AICoreSetup as event handler for MTX subscribe/unsubscribe."); + }); + configurer.eventHandler(new FioriRecommendationHandler(setupOpt, persistenceService)); + logger.info("Registered FioriRecommendationHandler for recommendations."); + } + + private static Optional getAIBinding(CdsEnvironment environment) { + return environment + .getServiceBindings() + .filter(b -> b.getServiceName().map(name -> name.equals("aicore")).orElse(false)) + .findFirst(); + } +} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/Application.java b/srv/src/main/java/com/sap/cds/feature/ai/Application.java new file mode 100644 index 0000000..344a0ca --- /dev/null +++ b/srv/src/main/java/com/sap/cds/feature/ai/Application.java @@ -0,0 +1,15 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai; + +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/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java b/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java new file mode 100644 index 0000000..16b31ad --- /dev/null +++ b/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java @@ -0,0 +1,332 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai; + +import com.sap.cds.feature.ai.client.AIClient; +import com.sap.cds.feature.ai.client.AICoreClient; +import com.sap.cds.feature.ai.client.MockAIClient; +import com.sap.cds.feature.ai.client.setup.AICoreSetup; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.services.cds.ApplicationService; +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.After; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.persistence.PersistenceService; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handler that provides Fiori AI recommendations for entities with value help. This implementation + * adds SAP_Recommendations to draft-enabled entities. + */ +@ServiceName(value = "*", type = ApplicationService.class) +public class FioriRecommendationHandler implements EventHandler { + + private final AIClient aiClient; + private final PersistenceService db; + private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); + 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 ASSOCIATION = "cds.Association"; + private static final String LARGE_BINARY = "cds.LargeBinary"; + private static final String VECTOR = "cds.Vector"; + private static final Set DRAFT_FIELDS = + Set.of( + "HasActiveEntity", + "HasDraftEntity", + "IsActiveEntity", + "DraftAdministrativeData_DraftUUID"); + + public FioriRecommendationHandler(Optional setupOpt, PersistenceService db) { + this.db = db; + if (setupOpt.isPresent()) { + logger.info("Registered AI Service Handler with AI Core setup."); + this.aiClient = new AICoreClient(setupOpt.get()); + } else { + logger.warn( + "No service binding to AI Service found, using mock implementation for Fiori recommendations!"); + this.aiClient = new MockAIClient(); + } + } + + /* + * After read event handler for Fiori AI recommendations: + * - Checks it's a draft read (IsActiveEntity = false), otherwise it's a read-only view and no + * recommendations are needed. + * - Finds all elements with value help annotations, these are the columns we want to predict + * - Fetches up to 2018 existing rows from DB as training context + * - Appends the current row with [PREDICT] as a placeholder + * - Sends the whole batch to the AI client → gets back predictions per row per column + * - Resolves human-readable descriptions for predicted IDs (e.g. genre.name for a predicted genre_ID) + * - Writes predictions into row.put("SAP_Recommendations", {...}) on the live result object, + */ + @After(event = CqnService.EVENT_READ, entity = "*") + public void afterRead(CdsReadEventContext context) { + List rows = context.getResult().listOf(Map.class); + + if (rows.isEmpty()) { + logger.debug("No result found, skipping predictions."); + return; + } + + if (rows.size() > 1) { + logger.debug( + "Multiple entites requested, recommendations are only available for single entity drafts."); + return; + } + + Map row = rows.get(0); + + // Only fetch predictions when editing a draft (IsActiveEntity = false) + Object isActiveEntity = row.get("IsActiveEntity"); + if (!Boolean.FALSE.equals(isActiveEntity)) { + logger.debug( + "Not editing a draft (IsActiveEntity={}), skipping predictions.", isActiveEntity); + return; + } + + // Get all fields that have value help annotations, if there are none, then there are no + // predictions to fetch + // Also Filter out association elements as they are navigation properties and not actual + // columns. + // When @Common.ValueListWithFixedValues is set on an association, CDS + // propagates the annotation to both the association element and its generated scalar + // foreign-key + // field. We only want the scalar fields for prediction, + // as those map to real DB columns and carry the actual values the AI model predicts. + List predictionColumns = + context + .getTarget() + .elements() + .filter( + e -> { + return e.findAnnotation(VALUE_LIST_ANNOTATION).isPresent() + || e.findAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION).isPresent(); + }) + .filter(e -> !e.getType().getQualifiedName().equals(ASSOCIATION)) + .map(e -> e.getName()) + .collect(Collectors.toList()); + if (predictionColumns.isEmpty()) { + logger.debug("No prediction columns found, skipping predictions."); + return; + } + + logger.info( + "Will fetch predictions for entity: " + + context.getTarget().getName() + + " and columns: " + + String.join(", ", predictionColumns)); + + // Columns to include in the context SELECT: exclude BLOBs, Vectors, and draft-only fields + // (mirrors the Node.js filter: type !== 'cds.LargeBinary' && type !== 'cds.Vector') + List contextColumns = + context + .getTarget() + .elements() + .filter(e -> !e.getType().getQualifiedName().equals(LARGE_BINARY)) + .filter(e -> !e.getType().getQualifiedName().equals(VECTOR)) + .filter(e -> !DRAFT_FIELDS.contains(e.getName())) + .filter(e -> !e.getType().getQualifiedName().equals(ASSOCIATION)) + .map(e -> e.getName()) + .collect(Collectors.toList()); + + // Now fetch up to 2018 context rows where all prediction fields are not null + CqnSelect contextQuery = + Select.from(context.getTarget().getQualifiedName()) + .columns(contextColumns.toArray(String[]::new)) + .where( + entity -> { + com.sap.cds.ql.Predicate condition = null; + // Iterate over all prediction columns, add condition that they must be not null + // and concatenate with AND + for (String col : predictionColumns) { + com.sap.cds.ql.Predicate notNull = entity.get(col).isNotNull(); + // concatenate: all columns we want predictions for must not be null + condition = condition == null ? notNull : condition.and(notNull); + } + return condition; + }) + .limit(2018); + List contextRows = new ArrayList<>(db.run(contextQuery).listOf(Map.class)); + if (contextRows.size() < 2) { + logger.info( + "Not enough context rows found with non-null values for prediction columns (minimum is 2), skipping predictions."); + return; + } + + // Add current row with [PREDICT] placeholders; strip draft-only fields first + // (mirrors Node.js: delete predictionRow.DraftAdministrativeData_DraftUUID etc.) + Map predictRow = new HashMap<>(row); + DRAFT_FIELDS.forEach(predictRow::remove); + for (String col : predictionColumns) { + predictRow.putIfAbsent(col, "[PREDICT]"); + } + if (!predictRow.values().contains("[PREDICT]")) { + logger.info( + "Current row already has values for all prediction columns, skipping predictions."); + return; + } + contextRows.add(predictRow); + + // Tenant ID is only needed for multi-tenant applications with AI Core, for single-tenant apps + // it is null, + // yet we need to get it here from the context to pass it to the AI client. + String tenantId = context.getUserInfo().getTenant(); + List> predictions = + aiClient.fetchPredictions(contextRows, predictionColumns, tenantId); + + // For now, we have requested predictions for exactly one row with [PREDICT] placeholders, + // which is why we expect a list of size 1 back from the aiClient. + // In the future, we could also support multiple rows with [PREDICT] placeholders. + if (predictions.isEmpty()) { + logger.warn("No predictions returned from AI client."); + return; + } + if (predictions.size() > 1) { + logger.warn("Multiple predictions returned from AI client, but only one was expected."); + return; + } + Map prediction = predictions.get(0); + + // With the call "fetchPredictions", we get the raw prediction of the format + // { ID : entity_ID, columnName: { prediction: predictionValue }, columnName2: { prediction: + // predictionValue } }. + // + // Left to do: bring the prediction into the below format and put that into the + // "SAP_Recommendations" + // map for the row, which Fiori expects. + // SAP_Recommendations: { + // columnName: { + // RecommendedFieldValue : predictionValue; + // RecommendedFieldIsSuggestion: true; + // RecommendedFieldDescription : "human readable description for the predicted value"; + // RecommendedFieldScoreValue : 0.5; // This number here to rank several predictions per + // column does not matter, since we only have one prediction. + // }, + // columnName2: { ... } + // } + + // To get a human readable recommendation for values where the ID is stored in the main table, + // we build a map for this, i.e. genre_ID -> genre.name, country_code -> country.name, etc. + // The text path is retrieved by stripping off the "_ID" suffix (if there is one) + // and looking for a @Common.Text annotation on the association element (e.g. genre) + // that points to the text field (e.g. genre.name). + Map textPaths = new HashMap<>(); + for (String col : predictionColumns) { + Optional path; + if (col.endsWith("_ID")) { + path = getTextPath(context, col.substring(0, col.length() - 3)); + if (path.isEmpty()) { + path = + getTextPath( + context, + col); // fallback: maybe the ID field itself has the @Common.Text annotation + } + } else { + path = getTextPath(context, col); + } + path.ifPresent(p -> textPaths.put(col, p)); + } + + Map recommendations = new HashMap<>(); + for (String col : predictionColumns) { + Map values = new HashMap<>(); + // Get the recommended value + Object obj = prediction.get(col); + if (obj instanceof List list && !list.isEmpty() && list.get(0) instanceof Map map) { + Object recommendedValue = map.get("prediction"); + // AI always returns strings; try to parse as number + if (recommendedValue instanceof String s) { + try { + recommendedValue = Integer.valueOf(s); + } catch (NumberFormatException ex) { + try { + recommendedValue = Double.valueOf(s); + } catch (NumberFormatException ex2) { + /* keep as string */ + } + } + } + final Object finalValue = recommendedValue; + values.put("RecommendedFieldValue", finalValue); + // Possibly also get the human readable description for that RecommendedFieldValue + values.put( + "RecommendedFieldDescription", + ""); // default to empty string if we cannot find a description + if (textPaths.containsKey( + col)) { // col might be e.g. genre_ID, then textPaths.get(col) could be "genre.name" + String[] parts = textPaths.get(col).split("\\."); // "genre.name" -> ["genre", "name"] + if (parts.length + != 2) { // The expected format for @Common.Text is "association.textField", if it's + // not in this format, we do not resolve the description. + logger.warn( + "Text path {} for column {} is not in expected format 'association.textField', skipping description resolution.", + textPaths.get(col), + col); + continue; + } + CqnSelect descQuery = + Select.from(context.getTarget().getQualifiedName()) + .columns(b -> b.get(col), b -> b.to(parts[0]).get(parts[1])) + .where(b -> b.get(col).eq(finalValue)); + db.run(descQuery) + .forEach( + descRow -> { // this would then return a row with genre_ID and name + Object text = descRow.get(parts[1]); + if (text != null) values.put("RecommendedFieldDescription", text.toString()); + }); + } + values.put( + "RecommendedFieldScoreValue", + 0.5); // If we had multiple predicions, we could use this field to rank them, since we + // only have one prediction, it does not matter what is put here. + values.put( + "RecommendedFieldIsSuggestion", + true); // If we had multiple predicions, we could use this field to select one + // suggestion, since we only have one prediction, it does not matter what is put + // here. + recommendations.put(col, List.of(values)); + } + } + row.put("SAP_Recommendations", recommendations); + } + + private Optional getTextPath(CdsReadEventContext context, String columnName) { + // Get all elements of entity we are looking at + var elements = context.getTarget().elements(); + return elements + .filter(e -> e.getName().equals(columnName)) // Find the element matching the column name + .findFirst() // .flatMap will only unpack the Optional, if we find an element with + // the given column name + .flatMap( + e -> + e.findAnnotation( + "@Common.Text")) // Check if that element has an annotation @Common.Text and get + // its value, e.g. "=": "genre.name" + .flatMap( + a -> { // .flatMap will only unpack the Optional, if we find an annotation + // @Common.Text + Object val = a.getValue(); + // Path annotations can be strings, i.e., "genre.name" or Maps, i.e., {"=": + // "genre.name"} + if (val instanceof String s) return Optional.of(s); + if (val instanceof Map m) { + Object eq = m.get("="); + return eq != null ? Optional.of(eq.toString()) : Optional.empty(); + } + return Optional.empty(); + }); + } +} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/AIClient.java b/srv/src/main/java/com/sap/cds/feature/ai/client/AIClient.java new file mode 100644 index 0000000..cc07d67 --- /dev/null +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/AIClient.java @@ -0,0 +1,20 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai.client; + +import java.util.List; +import java.util.Map; + +public interface AIClient { + /** + * Fetch predictions for the given rows. + * + * @param rows context rows + rows with [PREDICT] placeholders + * @param predictionColumns fields to predict + * @param tenantId the current tenant ID; null in single-tenant mode + * @return predicted values per row + */ + List> fetchPredictions( + List rows, List predictionColumns, String tenantId); +} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java b/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java new file mode 100644 index 0000000..b011adf --- /dev/null +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java @@ -0,0 +1,174 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai.client; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.JacksonConfiguration; +import com.sap.ai.sdk.foundationmodels.rpt.RptModel; +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.feature.ai.client.setup.AICoreSetup; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AICoreClient implements AIClient { + + private final AICoreSetup setup; + private static final Logger logger = LoggerFactory.getLogger(AICoreClient.class); + + // Retry inference call when resource group inference endpoint is not yet ready (403) + private static final int INFERENCE_READY_MAX_RETRIES = 8; + private static final long INFERENCE_READY_INITIAL_DELAY_MS = 500; + + // Safety-net: exclude draft/admin fields from the prediction request body. + // BLOB/Vector fields are already filtered upstream in FioriRecommendationHandler. + private static final Set EXCLUDED_FIELDS = + Set.of( + "HasActiveEntity", + "HasDraftEntity", + "IsActiveEntity", + "DraftAdministrativeData_DraftUUID", + "createdBy", + "modifiedBy", + "createdAt", + "modifiedAt"); + + public AICoreClient(AICoreSetup setup) { + this.setup = setup; + } + + @Override + /* + * Prediction flow: + * 1. resolveResourceGroup(tenantId) to get the AI Core resource group for the tenant + * 2. call predict() with the resource group, input rows and prediction columns + */ + public List> fetchPredictions( + List rows, List predictionColumns, String tenantId) { + try { + String resourceGroup = setup.resolveResourceGroup(tenantId); + return predict(resourceGroup, rows, predictionColumns); + } catch (Exception e) { + logger.error("Failed to fetch predictions from AI Core", e); + throw new RuntimeException("Failed to fetch predictions from AI Core", e); + } + } + + /* + * As described in https://sap.github.io/ai-sdk/docs/java/foundation-models/sap-rpt/table-completion#simple-table-completion + * 1. Build a PredictRequestPayload with the input rows and prediction column config + * 2. Call the AI Core API to get predictions + * 3. Parse and return the predictions as List> + */ + private List> predict( + String resourceGroup, List rows, List predictionColumns) { + var targetColumns = + predictionColumns.stream() + .map( + col -> + TargetColumnConfig.create() + .name(col) + .predictionPlaceholder(PredictionPlaceholder.create("[PREDICT]")) + .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) + .collect(Collectors.toList()); + + var sdkRows = + rows.stream() + .map( + row -> { + Map sdkRow = new LinkedHashMap<>(); + row.forEach( + (k, v) -> { + if (!EXCLUDED_FIELDS.contains(k) + && v != null + && (v instanceof String + || v instanceof Number + || v instanceof Boolean)) { + sdkRow.put(k.toString(), RowsInnerValue.create(v.toString())); + } + }); + return sdkRow; + }) + .collect(Collectors.toList()); + + var request = + PredictRequestPayload.create() + .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) + .rows(sdkRows) + .indexColumn("ID"); + + logger.debug( + "Sending prediction request for {} rows, {} target columns", + sdkRows.size(), + targetColumns.size()); + + // In multi-tenant mode, we manage the RPT-1 deployment lifecycle per resource group ourselves + // (create on subscribe, delete on unsubscribe), so we resolve the deployment ID explicitly. + // In single-tenant mode, we let the SDK resolve the deployment via forModel(), which queries + // AI Core for any running RPT-1 deployment in the resource group. + + // In multi-tenant mode, we cannot use RptClient.forModel(), because it calls + // AiCoreService().getInferenceDestination() with no arguments, + // which always resolves to the "default" resource group. Instead, we replicate + // the logic from RptClient using our per-tenant resource group, with the same arguments, i.e., + // JacksonConfiguration.getDefaultObjectMapper() and the default header "Content-Encoding: + // gzip". + var inferenceBuilder = new AiCoreService().getInferenceDestination(resourceGroup); + var destination = + AICoreSetup.isMultitenancyEnabled() + ? inferenceBuilder.usingDeploymentId(setup.getDeploymentForResourceGroup(resourceGroup)) + : inferenceBuilder.forModel(RptModel.SAP_RPT_1_SMALL); + var apiClient = + ApiClient.create(destination) + .withObjectMapper(JacksonConfiguration.getDefaultObjectMapper()); + var api = new DefaultApi(apiClient).withDefaultHeaders(Map.of("Content-Encoding", "gzip")); + + // AI Core inference endpoints for freshly created resource groups may return 403 + // until the endpoint is fully provisioned — retry with exponential backoff. + long delay = INFERENCE_READY_INITIAL_DELAY_MS; + for (int i = 0; i < INFERENCE_READY_MAX_RETRIES; i++) { + try { + var response = api.predict(request); + logger.debug("Prediction response id: {}", response.getId()); + try { + return JacksonConfiguration.getDefaultObjectMapper() + .convertValue(response.getPredictions(), new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException("Failed to parse prediction response", e); + } + } catch (OpenApiRequestException e) { + if (AICoreSetup.notReadyYet(e) && i < INFERENCE_READY_MAX_RETRIES - 1) { + logger.debug( + "Inference endpoint for resource group {} not ready yet (403), retrying in {} ms ({}/{})", + resourceGroup, + delay, + i + 1, + INFERENCE_READY_MAX_RETRIES); + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for inference endpoint", ie); + } + delay *= 2; + } else { + throw e; + } + } + } + throw new IllegalStateException("predict() exited retry loop unexpectedly"); + } +} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/MockAIClient.java b/srv/src/main/java/com/sap/cds/feature/ai/client/MockAIClient.java new file mode 100644 index 0000000..8367a80 --- /dev/null +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/MockAIClient.java @@ -0,0 +1,56 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai.client; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MockAIClient implements AIClient { + + private static final Logger logger = LoggerFactory.getLogger(MockAIClient.class); + + @Override + public List> fetchPredictions( + List rows, List predictionColumns, String tenantId) { + List> predictions = new ArrayList<>(); + Random random = new Random(); + + for (Map 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)) + .collect(java.util.stream.Collectors.toList()); + Object contextValue = + availableValues.isEmpty() + ? null + : availableValues.get( + random.nextInt( + availableValues + .size())); // get a random value from the existing values for this + // column + Map predictionEntry = new HashMap<>(); + predictionEntry.put("prediction", contextValue); + prediction.put(col, List.of(predictionEntry)); + } + } + if (addPrediction) { + prediction.put("ID", row.get("ID")); + predictions.add(prediction); + } + } + logger.info("Generated mock predictions: " + predictions.toString()); + return predictions; + } +} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java new file mode 100644 index 0000000..398e1fe --- /dev/null +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java @@ -0,0 +1,365 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai.client.setup; + +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiConfiguration; +import com.sap.ai.sdk.core.model.AiConfigurationBaseData; +import com.sap.ai.sdk.core.model.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.AiDeploymentStatus; +import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupBase; +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.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 java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(DeploymentService.DEFAULT_NAME) +public class AICoreSetup implements EventHandler { + + private static final String DEFAULT_RESOURCE_GROUP = "default"; + private static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; + private static final String RPT_SCENARIO_ID = "foundation-models"; + private static final String RPT_EXECUTABLE_ID = "aicore-sap"; + private static final String RPT_CONFIG_NAME = "sap-rpt-1-small"; + private static final String RPT_MODEL_NAME = "sap-rpt-1-small"; + private static final String RPT_MODEL_VERSION = "latest"; + // Max retries and initial delay (ms) when polling for RUNNING of deployment or readiness of + // resource group + private static final int AICORE_OPS_MAX_RETRIES = 10; + private static final long AICORE_OPS_INITIAL_DELAY_MS = 300; + private static final Logger logger = LoggerFactory.getLogger(AICoreSetup.class); + + // In-memory cache: tenantId -> resourceGroupId + private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); + // In-memory cache: resourceGroupId -> RPT-1 deploymentId + private final Map resourceGroupDeploymentCache = new ConcurrentHashMap<>(); + + // For testing + Map getTenantResourceGroupCache() { + return tenantResourceGroupCache; + } + + Map getResourceGroupDeploymentCache() { + return resourceGroupDeploymentCache; + } + + public AICoreSetup() {} + + /** + * Called automatically after a tenant subscribes: Creates an AI Core resource group for the + * tenant. + */ + @After(event = DeploymentService.EVENT_SUBSCRIBE) + public void afterSubscribe(SubscribeEventContext context) { + String tenantId = context.getTenant(); + logger.info("Creating AI Core resources for tenant: {}", tenantId); + try { + String resourceGroupId = getResourceGroupForTenant(tenantId); + logger.info("AI Core resource group created: {} for tenant: {}", resourceGroupId, tenantId); + } catch (Exception e) { + // Don't throw - let subscription succeed + logger.error( + "Failed to create AI Core resources for tenant: {} (will retry on first prediction)", + tenantId, + e); + } + } + + /** + * Called automatically before a tenant unsubscribes: Deletes the AI Core resource group for the + * tenant. + */ + @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + public void beforeUnsubscribe(UnsubscribeEventContext context) { + String tenantId = context.getTenant(); + logger.info("Deleting AI Core resources for tenant: {}", tenantId); + try { + deleteResourceGroupForTenant(tenantId); + logger.info("AI Core resources deleted for tenant: {}", tenantId); + } catch (Exception e) { + // Don't throw - let unsubscription succeed + logger.warn( + "Failed to delete AI Core resources for tenant: {} - {}", tenantId, e.getMessage()); + } + } + + public static boolean isMultitenancyEnabled() { + return Boolean.parseBoolean( + System.getProperty( + "cds.multitenancy.enabled", + System.getenv().getOrDefault("CDS_MULTITENANCY_ENABLED", "false"))); + } + + /** + * Resolves the resource group for the given tenant. In multi-tenant mode, checks for an existing + * resource group or creates one. In single-tenant mode, returns the default resource group. + */ + public String resolveResourceGroup(String tenantId) { + if (isMultitenancyEnabled()) { + return getResourceGroupForTenant(tenantId); + } + return DEFAULT_RESOURCE_GROUP; + } + + /** + * Returns the resource group for a tenant, creating one in AI Core if it doesn't exist yet. + * Caches the result in memory. + */ + private String getResourceGroupForTenant(String tenantId) { + String cached = tenantResourceGroupCache.get(tenantId); + if (cached != null) { + return cached; + } + ResourceGroupApi api = new ResourceGroupApi(); + List labelSelector = List.of(TENANT_LABEL_KEY + "=" + tenantId); + BckndResourceGroupList result = api.getAll(null, null, null, null, null, null, labelSelector); + List resources = result.getResources(); + if (resources != null && !resources.isEmpty()) { + String resourceGroupId = resources.get(0).getResourceGroupId(); + tenantResourceGroupCache.put(tenantId, resourceGroupId); + return resourceGroupId; + } + String createdId = createResourceGroupForTenant(tenantId, api); + tenantResourceGroupCache.put(tenantId, createdId); + return createdId; + } + + // See + // https://javadoc.io/doc/com.sap.ai.sdk/core/latest/com/sap/ai/sdk/core/model/BckndResourceGroupsPostRequest.html + private String createResourceGroupForTenant(String tenantId, ResourceGroupApi api) { + // This resourceGroupId is needed for the request, will fail otherwise + String resourceGroupId = UUID.randomUUID().toString(); + BckndResourceGroupLabel label = + BckndResourceGroupLabel.create().key(TENANT_LABEL_KEY).value(tenantId); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create() + .resourceGroupId(resourceGroupId) + .labels(List.of(label)); + BckndResourceGroupBase response = api.create(request); + logger.info("Created resource group {} for tenant {}", resourceGroupId, tenantId); + return resourceGroupId; + } + + /** + * Returns the RPT-1 deployment ID for the given resource group, creating configuration and + * deployment if none exists. Polls until the deployment reaches RUNNING status, which might take + * a while. Caches the result in memory. + */ + public String getDeploymentForResourceGroup(String resourceGroup) { + String cached = resourceGroupDeploymentCache.get(resourceGroup); + if (cached != null) { + return cached; + } + DeploymentApi deploymentApi = new DeploymentApi(); + // Look for an existing running or pending RPT-1 deployment in this resource group. + AiDeploymentList deploymentList = + queryDeploymentsFromResourceGroupUntilReady(deploymentApi, resourceGroup); + Optional existing = + deploymentList.getResources().stream() + .filter( + d -> + RPT_CONFIG_NAME.equals(d.getConfigurationName()) + && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) + || AiDeploymentStatus.PENDING.equals(d.getStatus()))) + .findFirst(); + if (existing.isPresent()) { + String deploymentId = existing.get().getId(); + resourceGroupDeploymentCache.put(resourceGroup, deploymentId); + return deploymentId; + } + + // No deployment found: we check if there is a configuration, if not create one and then create + // a deployment. + // The resource group should be ready for this call, since + // queryDeploymentsFromResourceGroupUntilReady made sure it is. + ConfigurationApi configApi = new ConfigurationApi(); + AiConfigurationList configList = + configApi.query(resourceGroup, RPT_SCENARIO_ID, null, null, null, null, null, null); + Optional existingConfig = + configList.getResources().stream() + .filter(c -> RPT_CONFIG_NAME.equals(c.getName())) + .findFirst(); + + String configId; + if (existingConfig.isPresent()) { + configId = existingConfig.get().getId(); + logger.info( + "Reusing existing RPT-1 configuration {} in resource group {}", configId, resourceGroup); + } else { + // Configuration creation is synchronous and should be fast, so we don't implement a retry + // loop here. + AiConfigurationBaseData configRequest = + AiConfigurationBaseData.create() + .name(RPT_CONFIG_NAME) + .executableId(RPT_EXECUTABLE_ID) + .scenarioId(RPT_SCENARIO_ID); + configRequest.parameterBindings( + List.of( + AiParameterArgumentBinding.create().key("modelName").value(RPT_MODEL_NAME), + AiParameterArgumentBinding.create().key("modelVersion").value(RPT_MODEL_VERSION))); + configId = configApi.create(resourceGroup, configRequest).getId(); + logger.info("Created RPT-1 configuration {} in resource group {}", configId, resourceGroup); + } + + // Now create a deployment for the configuration and poll until it's running and usable. + long delay = AICORE_OPS_INITIAL_DELAY_MS; + for (int i = 0; i < AICORE_OPS_MAX_RETRIES; i++) { + try { + var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); + var deployResponse = deploymentApi.create(resourceGroup, deployRequest); + String deploymentId = deployResponse.getId(); + logger.info( + "Created RPT-1 deployment {} in resource group {}, polling for RUNNING", + deploymentId, + resourceGroup); + + return pollUntilRunning(deploymentApi, resourceGroup, deploymentId); + } catch (OpenApiRequestException e) { + if (notReadyYet(e) && i < AICORE_OPS_MAX_RETRIES - 1) { + logger.debug( + "Deployment of resource group {} not ready yet ({}), retrying in {} ms ({}/{})", + resourceGroup, + e.getMessage(), + delay, + i + 1, + AICORE_OPS_MAX_RETRIES); + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + "Interrupted while waiting for resource group " + resourceGroup, ie); + } + delay *= 2; + } else { + throw e; + } + } + } + throw new IllegalStateException( + "Resource group " + resourceGroup + " never became ready for deployment"); + } + + private String pollUntilRunning( + DeploymentApi deploymentApi, String resourceGroup, String deploymentId) { + long delay = AICORE_OPS_INITIAL_DELAY_MS; + for (int i = 0; i < AICORE_OPS_MAX_RETRIES; i++) { + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for deployment " + deploymentId, e); + } + var current = deploymentApi.get(resourceGroup, deploymentId); + logger.debug( + "Deployment {} status: {} (retry {}/{})", + deploymentId, + current.getStatus(), + i + 1, + AICORE_OPS_MAX_RETRIES); + if (AiDeploymentStatus.RUNNING.equals(current.getStatus())) { + resourceGroupDeploymentCache.put(resourceGroup, deploymentId); + return deploymentId; + } + delay *= 2; + } + throw new RuntimeException( + "RPT-1 deployment " + + deploymentId + + " did not reach RUNNING status after " + + AICORE_OPS_MAX_RETRIES + + " retries"); + } + + /* + * Queries deployments in the given resource group. + * In case the resource group isn't ready yet, i.e., the request returns a 403 or 412 status code, + * we query until the resource group becomes ready; this is neccessary for further interaction with + * the resource group. + */ + private AiDeploymentList queryDeploymentsFromResourceGroupUntilReady( + DeploymentApi deploymentApi, String resourceGroup) { + long delay = AICORE_OPS_INITIAL_DELAY_MS; + for (int i = 0; i < AICORE_OPS_MAX_RETRIES; i++) { + try { + return deploymentApi.query( + resourceGroup, null, null, RPT_SCENARIO_ID, null, null, null, null); + } catch (OpenApiRequestException e) { + if (notReadyYet(e) && i < AICORE_OPS_MAX_RETRIES - 1) { + logger.debug( + "Resource group {} not ready yet ({}), retrying in {} ms ({}/{})", + resourceGroup, + e.getMessage(), + delay, + i + 1, + AICORE_OPS_MAX_RETRIES); + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + "Interrupted while waiting for resource group " + resourceGroup, ie); + } + delay *= 2; + } else { + throw e; + } + } + } + // unreachable — loop always returns or throws + throw new IllegalStateException("queryDeploymentsWithRetry exited unexpectedly"); + } + + /** + * Returns true if the exception (or its cause chain) represents an HTTP 403 or 412. AI Core + * returns 403 or 412 when a deployment is not yet provisioned or ready. In this case, we retry + * the operation that caused the exception after a delay. The SDK sometimes wraps the actual + * OpenApiRequestException in an IOException which is why we check the nested causes. + */ + public static boolean notReadyYet(OpenApiRequestException e) { + Throwable t = e; + while (t != null) { + if (t instanceof OpenApiRequestException oae) { + Integer code = oae.statusCode(); + if (Integer.valueOf(403).equals(code) || Integer.valueOf(412).equals(code)) { + return true; + } + } + t = t.getCause(); + } + return false; + } + + private void deleteResourceGroupForTenant(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.remove(tenantId); + if (resourceGroupId == null) { + logger.info("No cached resource group for tenant {}, nothing to delete", tenantId); + return; + } + resourceGroupDeploymentCache.remove(resourceGroupId); + new ResourceGroupApi().delete(resourceGroupId); + logger.info("Deleted resource group {} for tenant {}", resourceGroupId, tenantId); + } +} diff --git a/srv/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/srv/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..bb6e1d3 --- /dev/null +++ b/srv/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.feature.ai.AIRuntimeConfiguration \ No newline at end of file diff --git a/srv/src/main/resources/application.yaml b/srv/src/main/resources/application.yaml new file mode 100644 index 0000000..9ccd5c0 --- /dev/null +++ b/srv/src/main/resources/application.yaml @@ -0,0 +1,7 @@ + +--- +spring: + config.activate.on-profile: default + sql.init.platform: h2 +cds: + data-source.auto-config.enabled: false diff --git a/srv/src/test/java/com/sap/cds/feature/ai/FioriRecommendationHandlerTest.java b/srv/src/test/java/com/sap/cds/feature/ai/FioriRecommendationHandlerTest.java new file mode 100644 index 0000000..3024268 --- /dev/null +++ b/srv/src/test/java/com/sap/cds/feature/ai/FioriRecommendationHandlerTest.java @@ -0,0 +1,299 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.sap.cds.Result; +import com.sap.cds.ResultBuilder; +import com.sap.cds.feature.ai.client.MockAIClient; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsType; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.request.UserInfo; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FioriRecommendationHandlerTest { + + @Mock private PersistenceService db; + @Mock private CdsReadEventContext context; + @Mock private CdsEntity entity; + @Mock private UserInfo userInfo; + + private FioriRecommendationHandler cut; + + @BeforeEach + void setup() { + cut = new FioriRecommendationHandler(Optional.empty(), db); + } + + @Test + void emptyRows_returnsEarlyWithoutPredictions() { + when(context.getResult()).thenReturn(result()); + cut.afterRead(context); + verifyNoInteractions(db); + } + + @Test + void multipleRows_returnsEarlyWithoutPredictions() { + when(context.getResult()) + .thenReturn( + result( + Map.of("ID", "1", "IsActiveEntity", false), + Map.of("ID", "2", "IsActiveEntity", false))); + cut.afterRead(context); + verifyNoInteractions(db); + } + + @Test + void activeEntity_returnsEarlyWithoutPredictions() { + when(context.getResult()).thenReturn(result(Map.of("ID", "1", "IsActiveEntity", true))); + cut.afterRead(context); + verifyNoInteractions(db); + } + + @Test + void noPredictionColumns_returnsEarlyWithoutPredictions() { + // association-typed element: passes ValueList filter but is excluded by the Association filter + CdsElement assocEl = mock(CdsElement.class); + when(assocEl.findAnnotation("@Common.ValueList")) + .thenReturn(Optional.of(mock(CdsAnnotation.class))); + CdsType type = mock(CdsType.class); + when(type.getQualifiedName()).thenReturn("cds.Association"); + when(assocEl.getType()).thenReturn(type); + when(context.getTarget()).thenReturn(entity); + when(entity.elements()).thenAnswer(inv -> java.util.stream.Stream.of(assocEl)); + when(context.getResult()).thenReturn(result(draftRow("title", "foo"))); + cut.afterRead(context); + verifyNoInteractions(db); + } + + @Test + void notEnoughContextRows_returnsEarlyWithoutPredictions() { + setupEntity(genreIdEl()); + Map row = draftRow("genre_ID", null); + when(context.getResult()).thenReturn(result(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows(List.of(Map.of("ID", "x1", "genre_ID", 12))).result()); + cut.afterRead(context); + assertThat(row).doesNotContainKey("SAP_Recommendations"); + } + + @Test + void allColumnsAlreadyFilled_returnsEarlyWithoutPredictions() { + setupEntity(genreIdEl()); + Map row = draftRow("genre_ID", 16); + when(context.getResult()).thenReturn(result(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + cut.afterRead(context); + assertThat(row).doesNotContainKey("SAP_Recommendations"); + } + + @Test + void emptyPredictions_returnsEarlyWithoutRecommendations() { + setupEntityWithUserInfo(genreIdEl()); + Map row = draftRow("genre_ID", null); + when(context.getResult()).thenReturn(result(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + try (MockedConstruction ignored = + mockConstruction( + MockAIClient.class, + (mock, ctx) -> + when(mock.fetchPredictions(any(), any(), any())).thenReturn(List.of()))) { + cut = new FioriRecommendationHandler(Optional.empty(), db); + cut.afterRead(context); + } + assertThat(row).doesNotContainKey("SAP_Recommendations"); + } + + @Test + void multiplePredictions_returnsEarlyWithoutRecommendations() { + setupEntityWithUserInfo(genreIdEl()); + Map row = draftRow("genre_ID", null); + when(context.getResult()).thenReturn(result(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + try (MockedConstruction ignored = + mockConstruction( + MockAIClient.class, + (mock, ctx) -> + when(mock.fetchPredictions(any(), any(), any())) + .thenReturn(List.of(Map.of("ID", "id-1"), Map.of("ID", "id-2"))))) { + cut = new FioriRecommendationHandler(Optional.empty(), db); + cut.afterRead(context); + } + assertThat(row).doesNotContainKey("SAP_Recommendations"); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void draftRow_withGenreAndCurrency_addsSapRecommendations() { + 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); + CdsAnnotation textAnn = mock(CdsAnnotation.class); + when(textAnn.getValue()).thenReturn("genre.name"); + setupEntityWithUserInfo( + element("genre_ID", "cds.Integer", true, Optional.empty()), + element("currency_code", "cds.String", true, Optional.empty()), + element("genre", "cds.Association", false, Optional.of(textAnn))); + when(context.getResult()).thenReturn(result(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()); + cut.afterRead(context); + 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 + @SuppressWarnings("unchecked") + void textAnnotation_asMap_withEqKey_resolvesTextPath() { + CdsAnnotation textAnn = mock(CdsAnnotation.class); + when(textAnn.getValue()).thenReturn(Map.of("=", "genre.name")); + setupEntityWithUserInfo( + genreIdEl(), element("genre", "cds.Association", false, Optional.of(textAnn))); + Map row = draftRow("genre_ID", null); + when(context.getResult()).thenReturn(result(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn(twoContextRows(), ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(context); + assertThat((Map) row.get("SAP_Recommendations")).containsKey("genre_ID"); + } + + @Test + @SuppressWarnings("unchecked") + void textAnnotation_asMap_withoutEqKey_noDescription() { + CdsAnnotation textAnn = mock(CdsAnnotation.class); + when(textAnn.getValue()).thenReturn(Map.of("other", "value")); + setupEntityWithUserInfo( + genreIdEl(), element("genre", "cds.Association", false, Optional.of(textAnn))); + Map row = draftRow("genre_ID", null); + when(context.getResult()).thenReturn(result(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + cut.afterRead(context); + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(((List>) recs.get("genre_ID")).get(0)) + .containsEntry("RecommendedFieldDescription", ""); + } + + @Test + @SuppressWarnings("unchecked") + void textPath_invalidFormat_skipsColumn() { + CdsAnnotation textAnn = mock(CdsAnnotation.class); + when(textAnn.getValue()).thenReturn("genre.parent.name"); + setupEntityWithUserInfo( + genreIdEl(), element("genre", "cds.Association", false, Optional.of(textAnn))); + Map row = draftRow("genre_ID", null); + when(context.getResult()).thenReturn(result(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + cut.afterRead(context); + assertThat((Map) row.get("SAP_Recommendations")).doesNotContainKey("genre_ID"); + } + + @Test + void blobAndVectorFields_areExcludedFromContextSelect() { + // Arrange: entity with a predictable genre_ID, a LargeBinary (image), and a Vector field + setupEntityWithUserInfo( + genreIdEl(), + element("image", "cds.LargeBinary", false, Optional.empty()), + element("embedding", "cds.Vector", false, Optional.empty())); + Map row = draftRow("genre_ID", null); + when(context.getResult()).thenReturn(result(row)); + + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())).thenReturn(twoContextRows()); + + cut.afterRead(context); + + // The SELECT sent to db.run() must not contain the BLOB or Vector column names + String selectSql = selectCaptor.getValue().toString(); + assertThat(selectSql).doesNotContain("image"); + assertThat(selectSql).doesNotContain("embedding"); + assertThat(selectSql).contains("genre_ID"); + } + + // ── helpers ──────────────────────────────────────────────────────────────── + + @SafeVarargs + private static Result result(Map... rows) { + return ResultBuilder.selectedRows(List.of(rows)).result(); + } + + private Map draftRow(String col, Object val) { + Map row = new HashMap<>(); + row.put("ID", "id-1"); + row.put("IsActiveEntity", false); + row.put(col, val); + return row; + } + + private Result twoContextRows() { + return ResultBuilder.selectedRows( + new ArrayList<>( + List.of(Map.of("ID", "x1", "genre_ID", 12), Map.of("ID", "x2", "genre_ID", 16)))) + .result(); + } + + private CdsElement genreIdEl() { + return element("genre_ID", "cds.Integer", true, Optional.empty()); + } + + private void setupEntity(CdsElement... elements) { + when(context.getTarget()).thenReturn(entity); + lenient().when(entity.getName()).thenReturn("Books"); + lenient().when(entity.getQualifiedName()).thenReturn("bookshop.Books"); + when(entity.elements()).thenAnswer(inv -> java.util.stream.Stream.of(elements)); + } + + private void setupEntityWithUserInfo(CdsElement... elements) { + setupEntity(elements); + when(context.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(null); // can be null, but must be callabe + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static CdsElement element( + String name, String cdsType, boolean hasValueList, Optional textAnnotation) { + CdsElement el = mock(CdsElement.class); + when(el.getName()).thenReturn(name); + CdsType type = mock(CdsType.class); + lenient().when(type.getQualifiedName()).thenReturn(cdsType); + lenient().when(el.getType()).thenReturn(type); + when(el.findAnnotation("@Common.ValueList")).thenReturn(Optional.empty()); + Optional valueListAnn = + hasValueList ? Optional.of(mock(CdsAnnotation.class)) : Optional.empty(); + when(el.findAnnotation("@Common.ValueListWithFixedValues")).thenReturn(valueListAnn); + lenient().when(el.findAnnotation("@Common.Text")).thenReturn((Optional) textAnnotation); + return el; + } +} diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java new file mode 100644 index 0000000..33b17a2 --- /dev/null +++ b/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java @@ -0,0 +1,229 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.ai.client.setup.AICoreSetup; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +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; + +/** + * Integration test verifying the full prediction flow against a real AI Core instance. + * + *

Required environment variables: AICORE_SERVICE_KEY – Full AI Core service key JSON: { + * "clientid": "...", "clientsecret": "...", "url": "...", "serviceurls": { "AI_API_URL": "..." } } + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AICoreClientPredictionTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AICoreClient cut; + private Map credentials; + private String resourceGroup; + + @BeforeAll + void setup() throws Exception { + String serviceKey = System.getenv("AICORE_SERVICE_KEY"); + assumeTrue(serviceKey != null, "Skipping integration test: AICORE_SERVICE_KEY env var not set"); + + credentials = MAPPER.readValue(serviceKey, new TypeReference<>() {}); + resourceGroup = "default"; + cut = new AICoreClient(new AICoreSetup()); + } + + /** + * Full prediction flow: 1. Check deployments in the resource group 2. If no RUNNING rpt-1 + * deployment exists, create one and wait for it to reach RUNNING 3. Call fetchPredictions with a + * small context row and a [PREDICT] row 4. Verify predictions are returned + */ + @Test + void prediction_deploymentMissingOrPresent_returnsPredictions() throws Exception { + String aiApiUrl = ((Map) credentials.get("serviceurls")).get("AI_API_URL").toString(); + String token = fetchToken(); + + // Step 1+2: ensure a RUNNING rpt-1 deployment exists (create if missing) + ensureRunningRpt1Deployment(aiApiUrl, token); + + // Step 3: build rows — two context rows + one [PREDICT] row + Map contextRow1 = new HashMap<>(); + contextRow1.put("ID", "ctx-1"); + contextRow1.put("genre_ID", 10); + contextRow1.put("title", "Eleonora"); + + Map contextRow2 = new HashMap<>(); + contextRow2.put("ID", "ctx-2"); + contextRow2.put("genre_ID", 20); + contextRow2.put("title", "Another Book"); + + Map predictRow = new HashMap<>(); + predictRow.put("ID", "predict-1"); + predictRow.put("genre_ID", "[PREDICT]"); + predictRow.put("title", "Eleonora"); + + List> predictions = + cut.fetchPredictions( + List.of(contextRow1, contextRow2, predictRow), List.of("genre_ID"), null); + + // Step 4: verify + assertThat(predictions) + .as("Should return at least one prediction result") + .isNotNull() + .isNotEmpty(); + + Map prediction = predictions.get(0); + assertThat(prediction).containsKey("genre_ID"); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** + * Checks whether a RUNNING rpt-1 deployment exists. If not, creates one and polls until it + * reaches RUNNING (up to ~5 minutes with exponential back-off). + */ + private void ensureRunningRpt1Deployment(String aiApiUrl, String token) throws Exception { + HttpClient client = HttpClient.newHttpClient(); + + // Check existing deployments + HttpResponse listResponse = + client.send( + HttpRequest.newBuilder() + .uri(URI.create(aiApiUrl + "/v2/lm/deployments")) + .header("Authorization", "Bearer " + token) + .header("AI-Resource-Group", resourceGroup) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + + Map listResult = + MAPPER.readValue(listResponse.body(), new TypeReference<>() {}); + List> deployments = + MAPPER.convertValue(listResult.get("resources"), new TypeReference<>() {}); + + boolean hasRunning = + deployments != null + && deployments.stream() + .anyMatch( + d -> + d.get("configurationName") != null + && d.get("configurationName").toString().contains("rpt-1") + && "RUNNING".equals(d.get("status"))); + + if (hasRunning) { + return; // nothing to do + } + + // Find the rpt-1 configuration + HttpResponse cfgResponse = + client.send( + HttpRequest.newBuilder() + .uri(URI.create(aiApiUrl + "/v2/lm/configurations")) + .header("Authorization", "Bearer " + token) + .header("AI-Resource-Group", resourceGroup) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + + Map cfgResult = MAPPER.readValue(cfgResponse.body(), new TypeReference<>() {}); + List> configurations = + MAPPER.convertValue(cfgResult.get("resources"), new TypeReference<>() {}); + assertThat(configurations) + .as( + "No rpt-1 configuration found in resource group " + + resourceGroup + + " — cannot create a deployment. Ensure the configuration exists.") + .isNotNull() + .isNotEmpty(); + + String configurationId = + configurations.stream() + .filter(c -> c.get("name") != null && c.get("name").toString().contains("rpt-1")) + .map(c -> c.get("id").toString()) + .findFirst() + .orElseThrow( + () -> + new AssertionError( + "No rpt-1 configuration found in resource group: " + resourceGroup)); + + // Create the deployment + String createBody = MAPPER.writeValueAsString(Map.of("configurationId", configurationId)); + HttpResponse createResponse = + client.send( + HttpRequest.newBuilder() + .uri(URI.create(aiApiUrl + "/v2/lm/deployments")) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .header("AI-Resource-Group", resourceGroup) + .POST(HttpRequest.BodyPublishers.ofString(createBody)) + .build(), + HttpResponse.BodyHandlers.ofString()); + + Map created = MAPPER.readValue(createResponse.body(), new TypeReference<>() {}); + String deploymentId = created.get("id").toString(); + + // Poll until RUNNING + for (int i = 0; i < 10; i++) { + long delay = 300L * (1L << i); + System.out.printf( + "Waiting %dms for deployment %s to reach RUNNING (attempt %d/10)%n", + delay, deploymentId, i + 1); + Thread.sleep(delay); + + token = fetchToken(); + HttpResponse statusResponse = + client.send( + HttpRequest.newBuilder() + .uri(URI.create(aiApiUrl + "/v2/lm/deployments/" + deploymentId)) + .header("Authorization", "Bearer " + token) + .header("AI-Resource-Group", resourceGroup) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + + Map status = + MAPPER.readValue(statusResponse.body(), new TypeReference<>() {}); + if ("RUNNING".equals(status.get("status"))) { + return; + } + } + throw new AssertionError( + "Deployment " + deploymentId + " did not reach RUNNING within timeout"); + } + + /** Fetches a fresh OAuth token using service key credentials. */ + private String fetchToken() throws Exception { + String body = + "grant_type=client_credentials" + + "&client_id=" + + URLEncoder.encode(credentials.get("clientid").toString(), "UTF-8") + + "&client_secret=" + + URLEncoder.encode(credentials.get("clientsecret").toString(), "UTF-8"); + + HttpResponse response = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create(credentials.get("url") + "/oauth/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + + Map data = MAPPER.readValue(response.body(), new TypeReference<>() {}); + return data.get("access_token").toString(); + } +} diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/MockAIClientTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/MockAIClientTest.java new file mode 100644 index 0000000..b9649ec --- /dev/null +++ b/srv/src/test/java/com/sap/cds/feature/ai/client/MockAIClientTest.java @@ -0,0 +1,146 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockAIClientTest { + + private MockAIClient cut; + + @BeforeEach + void setup() { + cut = new MockAIClient(); + } + + @Test + void noRowsReturnsEmpty() { + List> result = + cut.fetchPredictions(new ArrayList<>(), List.of("genre_ID"), null); + assertThat(result).isEmpty(); + } + + @Test + void rowWithoutPredictPlaceholderIsSkipped() { + Map row = new HashMap<>(); + row.put("ID", "id-1"); + row.put("genre_ID", 10); + + List> result = + cut.fetchPredictions(List.of(row), List.of("genre_ID"), null); + + assertThat(result).isEmpty(); + } + + @Test + void rowWithPredictPlaceholderReturnsPrediction() { + Map contextRow = new HashMap<>(); + contextRow.put("ID", "id-1"); + contextRow.put("genre_ID", 10); + + Map predictRow = new HashMap<>(); + predictRow.put("ID", "id-2"); + predictRow.put("genre_ID", "[PREDICT]"); + + List> result = + cut.fetchPredictions(List.of(contextRow, predictRow), List.of("genre_ID"), null); + + assertThat(result).hasSize(1); + assertThat(result.get(0).get("ID")).isEqualTo("id-2"); + + @SuppressWarnings("unchecked") + List> preds = (List>) result.get(0).get("genre_ID"); + assertThat(preds).hasSize(1); + assertThat(preds.get(0).get("prediction")).isEqualTo(10); + } + + @Test + void predictionPicksValueFromContextRows() { + Map c1 = new HashMap<>(); + c1.put("ID", "id-1"); + c1.put("genre_ID", 10); + + Map c2 = new HashMap<>(); + c2.put("ID", "id-2"); + c2.put("genre_ID", 20); + + Map predictRow = new HashMap<>(); + predictRow.put("ID", "id-3"); + predictRow.put("genre_ID", "[PREDICT]"); + + List> result = + cut.fetchPredictions(List.of(c1, c2, predictRow), List.of("genre_ID"), null); + + assertThat(result).hasSize(1); + @SuppressWarnings("unchecked") + List> preds = (List>) result.get(0).get("genre_ID"); + assertThat(preds.get(0).get("prediction")).isIn(10, 20); + } + + @Test + void multipleColumnsEachGetPrediction() { + Map contextRow = new HashMap<>(); + contextRow.put("ID", "id-1"); + contextRow.put("genre_ID", 10); + contextRow.put("currency_code", "EUR"); + + Map predictRow = new HashMap<>(); + predictRow.put("ID", "id-2"); + predictRow.put("genre_ID", "[PREDICT]"); + predictRow.put("currency_code", "[PREDICT]"); + + List> result = + cut.fetchPredictions( + List.of(contextRow, predictRow), List.of("genre_ID", "currency_code"), null); + + assertThat(result).hasSize(1); + Map prediction = result.get(0); + assertThat(prediction).containsKey("genre_ID"); + assertThat(prediction).containsKey("currency_code"); + } + + @Test + void noContextValuesResultsInNullPrediction() { + // Only the predict row, no context rows with real values + Map predictRow = new HashMap<>(); + predictRow.put("ID", "id-1"); + predictRow.put("genre_ID", "[PREDICT]"); + + List> result = + cut.fetchPredictions(List.of(predictRow), List.of("genre_ID"), null); + + assertThat(result).hasSize(1); + @SuppressWarnings("unchecked") + List> preds = (List>) result.get(0).get("genre_ID"); + assertThat(preds.get(0).get("prediction")).isNull(); + } + + @Test + void multipleRowsWithPredictEachGetPredicted() { + Map contextRow = new HashMap<>(); + contextRow.put("ID", "id-1"); + contextRow.put("genre_ID", 10); + + Map predictRow1 = new HashMap<>(); + predictRow1.put("ID", "id-2"); + predictRow1.put("genre_ID", "[PREDICT]"); + + Map predictRow2 = new HashMap<>(); + predictRow2.put("ID", "id-3"); + predictRow2.put("genre_ID", "[PREDICT]"); + + List> result = + cut.fetchPredictions( + List.of(contextRow, predictRow1, predictRow2), List.of("genre_ID"), null); + + assertThat(result).hasSize(2); + } +} diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java new file mode 100644 index 0000000..2ccb91a --- /dev/null +++ b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java @@ -0,0 +1,206 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.ai.client.setup; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.ai.client.AICoreClient; +import com.sap.cds.services.mt.SubscribeEventContext; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Integration test verifying the full AICoreSetup lifecycle against a real AI Core instance. + * + *

Required environment variables: AICORE_SERVICE_KEY – Full AI Core service key JSON: { + * "clientid": "...", "clientsecret": "...", "url": "...", "serviceurls": { "AI_API_URL": "..." } } + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AICoreSetupTest { + + private static final String TEST_TENANT = "it-test-tenant-" + System.currentTimeMillis(); + private static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Logger logger = LoggerFactory.getLogger(AICoreSetupTest.class); + + private AICoreSetup cut; + private AICoreClient client; + private Map credentials; + private String aiApiUrl; + + @BeforeAll + void setup() throws Exception { + String serviceKey = System.getenv("AICORE_SERVICE_KEY"); + assumeTrue(serviceKey != null, "Skipping integration test: AICORE_SERVICE_KEY env var not set"); + + credentials = MAPPER.readValue(serviceKey, new TypeReference<>() {}); + aiApiUrl = ((Map) credentials.get("serviceurls")).get("AI_API_URL").toString(); + + System.setProperty("cds.multitenancy.enabled", "true"); + cut = new AICoreSetup(); + client = new AICoreClient(cut); + } + + @AfterAll + void tearDown() { + System.clearProperty("cds.multitenancy.enabled"); + } + + /** + * Full lifecycle: 1. Subscribe → resource group created online + in local cache 2. + * fetchPredictions → RPT-1 deployment created online + in local cache 3. Unsubscribe → resource + * group deleted online + removed from both caches + */ + @Test + void lifecycle_subscribeCreatesPredictCreatesDeploymentUnsubscribeDeletesAll() throws Exception { + + // ── 1. Subscribe ────────────────────────────────────────────────────── + SubscribeEventContext subCtx = mock(SubscribeEventContext.class); + when(subCtx.getTenant()).thenReturn(TEST_TENANT); + cut.afterSubscribe(subCtx); + + String token = fetchToken(); + String resourceGroupId = findResourceGroupByTenant(token, TEST_TENANT); + assertThat(resourceGroupId) + .as("Resource group should exist online after subscribe") + .isNotNull(); + assertThat(cut.getTenantResourceGroupCache()) + .as("Resource group should be cached after subscribe") + .containsKey(TEST_TENANT); + + // ── 2. fetchPredictions ─────────────────────────────────────────────── + // This triggers RPT-1 deployment creation + polling until RUNNING. + // The inference call itself may fail (due to no meaningful data) but the deployment + // must be RUNNING before inference is attempted. + try { + client.fetchPredictions( + List.of(Map.of("ID", "1", "title", "test book")), List.of("genre"), TEST_TENANT); + } catch (RuntimeException e) { + // Inference errors are acceptable; we only assert the deployment was created. + } + + token = fetchToken(); + String deploymentId = findDeploymentForResourceGroup(token, resourceGroupId); + assertThat(deploymentId) + .as("RPT-1 deployment should exist online after fetchPredictions") + .isNotNull(); + assertThat(cut.getResourceGroupDeploymentCache()) + .as("Deployment should be cached after fetchPredictions") + .containsEntry(resourceGroupId, deploymentId); + + // ── 3. Unsubscribe ──────────────────────────────────────────────────── + UnsubscribeEventContext unsubCtx = mock(UnsubscribeEventContext.class); + when(unsubCtx.getTenant()).thenReturn(TEST_TENANT); + cut.beforeUnsubscribe(unsubCtx); + + // Caches are cleared synchronously + assertThat(cut.getTenantResourceGroupCache()) + .as("Resource group should be removed from cache after unsubscribe") + .doesNotContainKey(TEST_TENANT); + assertThat(cut.getResourceGroupDeploymentCache()) + .as("Deployment should be removed from cache after unsubscribe") + .doesNotContainKey(resourceGroupId); + + // AI Core deletes resource groups asynchronously — poll until gone + waitUntilResourceGroupGone(TEST_TENANT); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private void waitUntilResourceGroupGone(String tenant) throws Exception { + long delay = 500; + int maxRetries = 10; + for (int i = 0; i < maxRetries; i++) { + Thread.sleep(delay); + String id = findResourceGroupByTenant(fetchToken(), tenant); + if (id == null) return; + logger.debug( + "Resource group still present after unsubscribe, retrying in {} ms ({}/{})", + delay * 2, + i + 1, + maxRetries); + delay *= 2; + } + assertThat(findResourceGroupByTenant(fetchToken(), tenant)) + .as("Resource group should be gone online after unsubscribe") + .isNull(); + } + + private String findResourceGroupByTenant(String token, String tenant) throws Exception { + String encoded = URLEncoder.encode(TENANT_LABEL_KEY + "=" + tenant, "UTF-8"); + HttpResponse response = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create(aiApiUrl + "/v2/admin/resourceGroups?labelSelector=" + encoded)) + .header("Authorization", "Bearer " + token) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + Map result = MAPPER.readValue(response.body(), new TypeReference<>() {}); + List> resources = + MAPPER.convertValue(result.get("resources"), new TypeReference<>() {}); + if (resources == null || resources.isEmpty()) return null; + return resources.get(0).get("resourceGroupId").toString(); + } + + private String findDeploymentForResourceGroup(String token, String resourceGroupId) + throws Exception { + HttpResponse response = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create(aiApiUrl + "/v2/lm/deployments")) + .header("Authorization", "Bearer " + token) + .header("AI-Resource-Group", resourceGroupId) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + Map result = MAPPER.readValue(response.body(), new TypeReference<>() {}); + List> resources = + MAPPER.convertValue(result.get("resources"), new TypeReference<>() {}); + if (resources == null || resources.isEmpty()) return null; + return resources.stream() + .filter(d -> "RUNNING".equals(d.get("status")) || "PENDING".equals(d.get("status"))) + .findFirst() + .map(d -> d.get("id").toString()) + .orElse(null); + } + + private String fetchToken() throws Exception { + String body = + "grant_type=client_credentials" + + "&client_id=" + + URLEncoder.encode(credentials.get("clientid").toString(), "UTF-8") + + "&client_secret=" + + URLEncoder.encode(credentials.get("clientsecret").toString(), "UTF-8"); + HttpResponse response = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder() + .uri(URI.create(credentials.get("url") + "/oauth/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + Map data = MAPPER.readValue(response.body(), new TypeReference<>() {}); + return data.get("access_token").toString(); + } +} From 3067a41a431a082ac23b7f0ce21020fa920ade85 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Sat, 25 Apr 2026 20:50:17 +0200 Subject: [PATCH 04/42] Add artifacts for deployment --- samples/bookshop/.cdsrc.json | 6 + samples/bookshop/app/package.json | 11 ++ samples/bookshop/app/xs-app.json | 19 +++ samples/bookshop/db/package.json | 14 ++ samples/bookshop/db/src/.hdiconfig | 139 ++++++++++++++++++ samples/bookshop/db/undeploy.json | 7 + samples/bookshop/mta.yaml | 87 +++++++++++ samples/bookshop/srv/pom.xml | 16 +- .../srv/src/main/resources/application.yaml | 18 +++ samples/bookshop/xs-security.json | 10 ++ 10 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 samples/bookshop/app/package.json create mode 100644 samples/bookshop/app/xs-app.json create mode 100644 samples/bookshop/db/package.json create mode 100644 samples/bookshop/db/src/.hdiconfig create mode 100644 samples/bookshop/db/undeploy.json create mode 100644 samples/bookshop/mta.yaml create mode 100644 samples/bookshop/xs-security.json diff --git a/samples/bookshop/.cdsrc.json b/samples/bookshop/.cdsrc.json index 2c63c08..1cb5db6 100644 --- a/samples/bookshop/.cdsrc.json +++ b/samples/bookshop/.cdsrc.json @@ -1,2 +1,8 @@ { + "requires": { + "db": "hana", + "[production]": { + "auth": "xsuaa" + } + } } 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/xs-app.json b/samples/bookshop/app/xs-app.json new file mode 100644 index 0000000..5509e99 --- /dev/null +++ b/samples/bookshop/app/xs-app.json @@ -0,0 +1,19 @@ +{ + "welcomeFile": "/index.html", + "authenticationMethod": "route", + "routes": [ + { + "source": "^/odata/(.*)$", + "target": "/odata/$1", + "destination": "srv-api", + "authenticationType": "xsuaa", + "csrfProtection": false + }, + { + "source": "^/(.*)$", + "target": "$1", + "localDir": ".", + "authenticationType": "xsuaa" + } + ] +} 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/src/.hdiconfig b/samples/bookshop/db/src/.hdiconfig new file mode 100644 index 0000000..03673f2 --- /dev/null +++ b/samples/bookshop/db/src/.hdiconfig @@ -0,0 +1,139 @@ +{ + "file_suffixes": { + "csv": { + "plugin_name": "com.sap.hana.di.tabledata.source" + }, + "hdbafllangprocedure": { + "plugin_name": "com.sap.hana.di.afllangprocedure" + }, + "hdbanalyticprivilege": { + "plugin_name": "com.sap.hana.di.analyticprivilege" + }, + "hdbcalculationview": { + "plugin_name": "com.sap.hana.di.calculationview" + }, + "hdbcollection": { + "plugin_name": "com.sap.hana.di.collection" + }, + "hdbconstraint": { + "plugin_name": "com.sap.hana.di.constraint" + }, + "hdbdropcreatetable": { + "plugin_name": "com.sap.hana.di.dropcreatetable" + }, + "hdbflowgraph": { + "plugin_name": "com.sap.hana.di.flowgraph" + }, + "hdbfunction": { + "plugin_name": "com.sap.hana.di.function" + }, + "hdbgraphworkspace": { + "plugin_name": "com.sap.hana.di.graphworkspace" + }, + "hdbhadoopmrjob": { + "plugin_name": "com.sap.hana.di.virtualfunctionpackage.hadoop" + }, + "hdbindex": { + "plugin_name": "com.sap.hana.di.index" + }, + "hdblibrary": { + "plugin_name": "com.sap.hana.di.library" + }, + "hdbmigrationtable": { + "plugin_name": "com.sap.hana.di.table.migration" + }, + "hdbprocedure": { + "plugin_name": "com.sap.hana.di.procedure" + }, + "hdbprojectionview": { + "plugin_name": "com.sap.hana.di.projectionview" + }, + "hdbprojectionviewconfig": { + "plugin_name": "com.sap.hana.di.projectionview.config" + }, + "hdbreptask": { + "plugin_name": "com.sap.hana.di.reptask" + }, + "hdbresultcache": { + "plugin_name": "com.sap.hana.di.resultcache" + }, + "hdbrole": { + "plugin_name": "com.sap.hana.di.role" + }, + "hdbroleconfig": { + "plugin_name": "com.sap.hana.di.role.config" + }, + "hdbsearchruleset": { + "plugin_name": "com.sap.hana.di.searchruleset" + }, + "hdbsequence": { + "plugin_name": "com.sap.hana.di.sequence" + }, + "hdbstatistics": { + "plugin_name": "com.sap.hana.di.statistics" + }, + "hdbstructuredprivilege": { + "plugin_name": "com.sap.hana.di.structuredprivilege" + }, + "hdbsynonym": { + "plugin_name": "com.sap.hana.di.synonym" + }, + "hdbsynonymconfig": { + "plugin_name": "com.sap.hana.di.synonym.config" + }, + "hdbsystemversioning": { + "plugin_name": "com.sap.hana.di.systemversioning" + }, + "hdbtable": { + "plugin_name": "com.sap.hana.di.table" + }, + "hdbtabledata": { + "plugin_name": "com.sap.hana.di.tabledata" + }, + "hdbtabletype": { + "plugin_name": "com.sap.hana.di.tabletype" + }, + "hdbtrigger": { + "plugin_name": "com.sap.hana.di.trigger" + }, + "hdbview": { + "plugin_name": "com.sap.hana.di.view" + }, + "hdbvirtualfunction": { + "plugin_name": "com.sap.hana.di.virtualfunction" + }, + "hdbvirtualfunctionconfig": { + "plugin_name": "com.sap.hana.di.virtualfunction.config" + }, + "hdbvirtualpackagehadoop": { + "plugin_name": "com.sap.hana.di.virtualpackage.hadoop" + }, + "hdbvirtualpackagesparksql": { + "plugin_name": "com.sap.hana.di.virtualpackage.sparksql" + }, + "hdbvirtualprocedure": { + "plugin_name": "com.sap.hana.di.virtualprocedure" + }, + "hdbvirtualprocedureconfig": { + "plugin_name": "com.sap.hana.di.virtualprocedure.config" + }, + "hdbvirtualtable": { + "plugin_name": "com.sap.hana.di.virtualtable" + }, + "hdbvirtualtableconfig": { + "plugin_name": "com.sap.hana.di.virtualtable.config" + }, + "properties": { + "plugin_name": "com.sap.hana.di.tabledata.properties" + }, + "tags": { + "plugin_name": "com.sap.hana.di.tabledata.properties" + }, + "txt": { + "plugin_name": "com.sap.hana.di.copyonly" + }, + "hdbeshconfig": { + "plugin_name": "com.sap.hana.di.eshconfig" + } + } +} diff --git a/samples/bookshop/db/undeploy.json b/samples/bookshop/db/undeploy.json new file mode 100644 index 0000000..aa14b9f --- /dev/null +++ b/samples/bookshop/db/undeploy.json @@ -0,0 +1,7 @@ +[ + "src/gen/**/*.hdbview", + "src/gen/**/*.hdbindex", + "src/gen/**/*.hdbconstraint", + "src/gen/**/*_drafts.hdbtable", + "src/gen/**/*.hdbcalculationview" +] 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/srv/pom.xml b/samples/bookshop/srv/pom.xml index 2b9cc82..6415be7 100644 --- a/samples/bookshop/srv/pom.xml +++ b/samples/bookshop/srv/pom.xml @@ -1,7 +1,4 @@ - - + 4.0.0 @@ -62,6 +59,17 @@ spring-security-test test + + + com.sap.cds + cds-starter-cloudfoundry + runtime + + + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/samples/bookshop/srv/src/main/resources/application.yaml b/samples/bookshop/srv/src/main/resources/application.yaml index 3420efb..2f2e3ff 100644 --- a/samples/bookshop/srv/src/main/resources/application.yaml +++ b/samples/bookshop/srv/src/main/resources/application.yaml @@ -26,3 +26,21 @@ cds: 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/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/**" + ] + } +} From 6bb61266dc617844278d1787549061a2c4a157e8 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 29 Apr 2026 07:42:21 +0200 Subject: [PATCH 05/42] Actually provide option to define custom resource group for single tenant scenarios and update README --- README.md | 16 +--------------- .../srv/src/main/resources/application.yaml | 3 +++ .../cds/feature/ai/AIRuntimeConfiguration.java | 7 +++++-- .../cds/feature/ai/client/setup/AICoreSetup.java | 14 ++++++++++++-- .../ai/client/AICoreClientPredictionTest.java | 5 ++++- .../feature/ai/client/setup/AICoreSetupTest.java | 4 +++- 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c99683d..3f30706 100644 --- a/README.md +++ b/README.md @@ -90,21 +90,7 @@ The plugin will automatically create resource groups per tenant labeled with `ex In single-tenant setups the plugin uses the 'default' resource group and creates an RPT-1 deployment as well if none exists. -For single-tenant deployments you can change the resource group as follow in the `package.json`: - -```json -{ - "cds": { - "requires": { - "AICore": { - "resourceGroup": "CUSTOM_SINGLE_TENANT_RESOURCE_GROUP" - } - } - } -} -``` - -or +For single-tenant deployments you can change the resource group as follow in the `application.yaml`: ```yaml # application.yaml diff --git a/samples/bookshop/srv/src/main/resources/application.yaml b/samples/bookshop/srv/src/main/resources/application.yaml index 2f2e3ff..3d5bacd 100644 --- a/samples/bookshop/srv/src/main/resources/application.yaml +++ b/samples/bookshop/srv/src/main/resources/application.yaml @@ -14,6 +14,9 @@ spring: init: platform: h2 cds: + requires: + AICore: + resourceGroup: custom-resource-group security: mock: users: diff --git a/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java b/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java index f0da8a6..215c889 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java @@ -29,10 +29,13 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); logger.info("PersistenceService obtained from ServiceCatalog: " + (persistenceService != null)); - Optional bindingOpt = getAIBinding(configurer.getCdsRuntime().getEnvironment()); + Optional bindingOpt = getAIBinding(runtime.getEnvironment()); // If the AI Core service binding is present, create the AICoreSetup event handler to manage // resource groups for tenants. - Optional setupOpt = bindingOpt.map(b -> new AICoreSetup()); + // The binding itself does *not* need to be passed to the AICoreSetup; the AICoreSetup uses + // the com.sap.ai.sdk.core library which reads the binding directly from the environment + // variable AICORE_SERVICE_KEY. + Optional setupOpt = bindingOpt.map(b -> new AICoreSetup(runtime.getEnvironment())); setupOpt.ifPresent( setup -> { configurer.eventHandler(setup); diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java index 398e1fe..60ba7e4 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java @@ -19,6 +19,7 @@ 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.services.environment.CdsEnvironment; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; @@ -56,6 +57,8 @@ public class AICoreSetup implements EventHandler { // In-memory cache: resourceGroupId -> RPT-1 deploymentId private final Map resourceGroupDeploymentCache = new ConcurrentHashMap<>(); + private final CdsEnvironment environment; + // For testing Map getTenantResourceGroupCache() { return tenantResourceGroupCache; @@ -65,7 +68,9 @@ Map getResourceGroupDeploymentCache() { return resourceGroupDeploymentCache; } - public AICoreSetup() {} + public AICoreSetup(CdsEnvironment environment) { + this.environment = environment; + } /** * Called automatically after a tenant subscribes: Creates an AI Core resource group for the @@ -120,7 +125,12 @@ public String resolveResourceGroup(String tenantId) { if (isMultitenancyEnabled()) { return getResourceGroupForTenant(tenantId); } - return DEFAULT_RESOURCE_GROUP; + String group = + this.environment.getProperty( + "cds.requires.AICore.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); + group = group != null ? group : DEFAULT_RESOURCE_GROUP; + logger.info("Multitenancy disabled, using resource group: {}", group); + return group; } /** diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java index 33b17a2..ec574c9 100644 --- a/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java +++ b/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.ai.client.setup.AICoreSetup; +import com.sap.cds.services.environment.CdsEnvironment; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; @@ -20,6 +21,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import org.mockito.Mockito; /** * Integration test verifying the full prediction flow against a real AI Core instance. @@ -43,7 +45,8 @@ void setup() throws Exception { credentials = MAPPER.readValue(serviceKey, new TypeReference<>() {}); resourceGroup = "default"; - cut = new AICoreClient(new AICoreSetup()); + CdsEnvironment environment = Mockito.mock(CdsEnvironment.class); + cut = new AICoreClient(new AICoreSetup(environment)); } /** diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java index 2ccb91a..fde3add 100644 --- a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java +++ b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.ai.client.AICoreClient; +import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.mt.SubscribeEventContext; import com.sap.cds.services.mt.UnsubscribeEventContext; import java.net.URI; @@ -54,7 +55,8 @@ void setup() throws Exception { aiApiUrl = ((Map) credentials.get("serviceurls")).get("AI_API_URL").toString(); System.setProperty("cds.multitenancy.enabled", "true"); - cut = new AICoreSetup(); + CdsEnvironment environment = mock(CdsEnvironment.class); + cut = new AICoreSetup(environment); client = new AICoreClient(cut); } From ef6e8a56944d741928f8f9bff25d31e77f656b4e Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 29 Apr 2026 07:53:29 +0200 Subject: [PATCH 06/42] Fix name in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b899176..0dac673 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "cds-feature-ai-java", + "name": "cds-feature-ai", "version": "1.0.0-SNAPSHOT", "description": "CAP Java AI Features Plugin", "license": "Apache-2.0", From f9c0d70949ebd5039da1e32b3996d5ab4f95c97b Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Thu, 30 Apr 2026 12:05:18 +0200 Subject: [PATCH 07/42] Cleanup: pom, unneeded files --- pom.xml | 313 +++++++++++++----- samples/bookshop/pom.xml | 8 +- srv/pom.xml | 174 +++++----- .../com/sap/cds/feature/ai/Application.java | 15 - .../ai/FioriRecommendationHandler.java | 2 +- .../feature/ai/client/setup/AICoreSetup.java | 3 +- srv/src/main/resources/application.yaml | 7 - .../resources/spotbugs-exclusion-filter.xml | 24 ++ 8 files changed, 355 insertions(+), 191 deletions(-) delete mode 100644 srv/src/main/java/com/sap/cds/feature/ai/Application.java delete mode 100644 srv/src/main/resources/application.yaml create mode 100644 srv/src/main/resources/spotbugs-exclusion-filter.xml diff --git a/pom.xml b/pom.xml index 5eee0cf..ca0e387 100644 --- a/pom.xml +++ b/pom.xml @@ -3,35 +3,82 @@ 4.0.0 com.sap.cds - cds-feature-ai-parent + cds-feature-ai-root ${revision} pom - cds-feature-ai parent + CDS Feature for AI - Root + This artifact is a CAP Java plugin that provides smart recommendations using the SAP RPT-1 model. + + + 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 + + srv + + + central + MavenCentral + https://central.sonatype.com + + + artifactory + Artifactory_DMZ-snapshots + https://common.repositories.cloud.sap/artifactory/cap-java + + + 1.0.0-SNAPSHOT - 17 - 4.4.1 + 17 + 4.6.1 3.5.6 1.18.0 + 0.8.14 + 6.0.3 + 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.2 + 1.7.3 + 5.28.0 + 4.9.8.3 + 1.23.0 - https://nodejs.org/dist/ - UTF-8 - - com/sap/cds/feature/ai/generated/ + + true - com.sap.cds cds-services-bom @@ -40,21 +87,73 @@ import - - org.springframework.boot - spring-boot-dependencies - ${spring.boot.version} + 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.sap.cds + cds-feature-ai + ${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 @@ -63,48 +162,71 @@ org.jacoco jacoco-maven-plugin - 0.8.14 + ${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} org.pitest pitest-maven - 1.23.0 + ${pitest.maven.plugin.version} + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs.maven.plugin.version} - - - maven-compiler-plugin - 3.14.1 - - ${jdk.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 @@ -127,27 +249,86 @@ - + + 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 - 3.6.2 - Project Structure Checks + no-duplicate-declared-dependencies enforce + 3.6.3 - ${jdk.version} + ${java.version} - - true @@ -185,39 +366,7 @@ - - - - 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/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index 2f39ab7..0e5e0ea 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -16,12 +16,10 @@ 1.0.0-SNAPSHOT - 17 + 17 4.4.0 3.5.6 - https://nodejs.org/dist/ - UTF-8 @@ -74,7 +72,7 @@ maven-compiler-plugin 3.14.1 - ${jdk.version} + ${java.version} UTF-8 @@ -138,7 +136,7 @@ 3.6.3 - ${jdk.version} + ${java.version} diff --git a/srv/pom.xml b/srv/pom.xml index 9c73f7e..0fddd54 100644 --- a/srv/pom.xml +++ b/srv/pom.xml @@ -4,18 +4,16 @@ com.sap.cds - cds-feature-ai-parent + cds-feature-ai-root ${revision} cds-feature-ai jar - cds-feature-ai + CDS Feature for AI - - com.sap.cds cds-starter-spring-boot @@ -24,18 +22,20 @@ com.sap.cds cds-services-utils - - - - org.slf4j - slf4j-api - - - - - com.sap.ai.sdk.foundationmodels - sap-rpt - ${ai-sdk.version} + + + com.sap.cds + cds-adapter-api + + + com.sap.cds + cds4j-core + + + com.sap.cloud.mt + tools + + @@ -45,61 +45,76 @@ - org.springframework.boot - spring-boot-devtools - true - - - - org.springframework.boot - spring-boot-starter-test - test - - - - com.sap.cds - cds-adapter-odata-v4 - runtime + com.sap.ai.sdk.foundationmodels + sap-rpt + ${ai-sdk.version} - - com.h2database - h2 - runtime - + - - org.springframework.security - spring-security-test - test - + ${project.artifactId} - - org.springframework.boot - spring-boot-maven-plugin - ${spring.boot.version} + org.pitest + pitest-maven - false + + com.sap.cds.feature.ai.* + + + CONSTRUCTOR_CALLS + VOID_METHOD_CALLS + NON_VOID_METHOD_CALLS + CONDITIONALS_BOUNDARY + EMPTY_RETURNS + NEGATE_CONDITIONALS + REMOVE_CONDITIONALS_EQUAL_IF + REMOVE_CONDITIONALS_EQUAL_ELSE + REMOVE_CONDITIONALS_ORDER_IF + REMOVE_CONDITIONALS_ORDER_ELSE + + 95 + 90 + + + + + org.pitest + pitest-junit5-plugin + ${pitest.maven.plugin.version} + + + + + + + maven-clean-plugin + + + + ./ + + .flattened-pom.xml + + + - repackage + auto-clean - repackage + clean - - exec - + clean - + com.sap.cds cds-maven-plugin @@ -165,37 +180,38 @@ - - org.pitest - pitest-maven + org.jacoco + jacoco-maven-plugin - - com.sap.cds.feature.ai.* - - - CONSTRUCTOR_CALLS - VOID_METHOD_CALLS - NON_VOID_METHOD_CALLS - REMOVE_CONDITIONALS_ORDER_ELSE - CONDITIONALS_BOUNDARY - EMPTY_RETURNS - NEGATE_CONDITIONALS - REMOVE_CONDITIONALS_EQUAL_IF - REMOVE_CONDITIONALS_EQUAL_ELSE - REMOVE_CONDITIONALS_ORDER_IF - - 80 - 70 + + ${excluded.generation.package}**/* + - - - org.pitest - pitest-junit5-plugin - 1.2.3 - - + + + jacoco-initialize + + prepare-agent + + + + jacoco-site-report-all-tests + + report + + verify + + + jacoco-site-report-only-unit-tests + + report + + test + + + diff --git a/srv/src/main/java/com/sap/cds/feature/ai/Application.java b/srv/src/main/java/com/sap/cds/feature/ai/Application.java deleted file mode 100644 index 344a0ca..0000000 --- a/srv/src/main/java/com/sap/cds/feature/ai/Application.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai; - -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/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java b/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java index 16b31ad..28d8b43 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java @@ -255,7 +255,7 @@ public void afterRead(CdsReadEventContext context) { try { recommendedValue = Double.valueOf(s); } catch (NumberFormatException ex2) { - /* keep as string */ + recommendedValue = s; // keep as string } } } diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java index 60ba7e4..27549f6 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java @@ -15,7 +15,6 @@ import com.sap.ai.sdk.core.model.AiDeploymentStatus; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; import com.sap.ai.sdk.core.model.BckndResourceGroup; -import com.sap.ai.sdk.core.model.BckndResourceGroupBase; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.ai.sdk.core.model.BckndResourceGroupList; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; @@ -167,7 +166,7 @@ private String createResourceGroupForTenant(String tenantId, ResourceGroupApi ap BckndResourceGroupsPostRequest.create() .resourceGroupId(resourceGroupId) .labels(List.of(label)); - BckndResourceGroupBase response = api.create(request); + api.create(request); logger.info("Created resource group {} for tenant {}", resourceGroupId, tenantId); return resourceGroupId; } diff --git a/srv/src/main/resources/application.yaml b/srv/src/main/resources/application.yaml deleted file mode 100644 index 9ccd5c0..0000000 --- a/srv/src/main/resources/application.yaml +++ /dev/null @@ -1,7 +0,0 @@ - ---- -spring: - config.activate.on-profile: default - sql.init.platform: h2 -cds: - data-source.auto-config.enabled: false diff --git a/srv/src/main/resources/spotbugs-exclusion-filter.xml b/srv/src/main/resources/spotbugs-exclusion-filter.xml new file mode 100644 index 0000000..ee4e277 --- /dev/null +++ b/srv/src/main/resources/spotbugs-exclusion-filter.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + From a65799f0fd743e045e0b5e4e4143553c0c61cd09 Mon Sep 17 00:00:00 2001 From: Lisa Nebel Date: Fri, 1 May 2026 14:56:42 +0200 Subject: [PATCH 08/42] Update srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java Co-authored-by: Matthias Braun --- .../java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java index 27549f6..f88d1ea 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java @@ -85,7 +85,7 @@ public void afterSubscribe(SubscribeEventContext context) { } catch (Exception e) { // Don't throw - let subscription succeed logger.error( - "Failed to create AI Core resources for tenant: {} (will retry on first prediction)", + "Failed to create AI Core resources for tenant {} (retrying on demand)", tenantId, e); } From 99076c1b068dee8d9174bfe465cdd1ae27400b32 Mon Sep 17 00:00:00 2001 From: Lisa Nebel Date: Fri, 1 May 2026 14:57:13 +0200 Subject: [PATCH 09/42] Update README.md Co-authored-by: Simon Engel --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f30706..f83456e 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ pom.xml: package.json: ```json "dependencies": { - "@cap-js/ai": "" + "@cap-js/ai": "1.0.0" } ``` From 8c77bd01cda22b83812d6cdddd3fc79ca048858b Mon Sep 17 00:00:00 2001 From: Lisa Nebel Date: Fri, 1 May 2026 14:57:45 +0200 Subject: [PATCH 10/42] Update REUSE.toml Co-authored-by: Simon Engel --- REUSE.toml | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/REUSE.toml b/REUSE.toml index 7501d58..576b23e 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,23 +1,11 @@ version = 1 -SPDX-PackageName = "" -SPDX-PackageSupplier = "" -SPDX-PackageDownloadLocation = "" +SPDX-PackageName = "cds-feature-ai" +SPDX-PackageSupplier = "ospo@sap.com" +SPDX-PackageDownloadLocation = "https://github.com/cap-java/cds-feature-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 = "" +path = "**" precedence = "aggregate" -SPDX-FileCopyrightText = " SAP SE or an SAP affiliate company and contributors" +SPDX-FileCopyrightText = "2026 SAP SE or an SAP affiliate company and @cap-java/cds-feature-ai contributors" SPDX-License-Identifier = "Apache-2.0" - -[[annotations]] -path = "" -precedence = "aggregate" -SPDX-FileCopyrightText = "" -SPDX-License-Identifier = "" - -[[annotations]] -path = "" -precedence = "aggregate" -SPDX-FileCopyrightText = "" -SPDX-License-Identifier = "" From 88aca3ddd5d7e4959125128e1c123782516a92af Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Fri, 1 May 2026 15:03:41 +0200 Subject: [PATCH 11/42] Rename AICoreSetup -> AICoreSetupHandler + some cosmetics --- .../feature/ai/AIRuntimeConfiguration.java | 29 ++++++-------- .../ai/FioriRecommendationHandler.java | 4 +- .../cds/feature/ai/client/AICoreClient.java | 15 ++++--- ...CoreSetup.java => AICoreSetupHandler.java} | 40 +++++++++---------- .../ai/client/AICoreClientPredictionTest.java | 4 +- ...pTest.java => AICoreSetupHandlerTest.java} | 8 ++-- 6 files changed, 50 insertions(+), 50 deletions(-) rename srv/src/main/java/com/sap/cds/feature/ai/client/setup/{AICoreSetup.java => AICoreSetupHandler.java} (92%) rename srv/src/test/java/com/sap/cds/feature/ai/client/setup/{AICoreSetupTest.java => AICoreSetupHandlerTest.java} (98%) diff --git a/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java b/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java index 215c889..2482669 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java @@ -3,9 +3,8 @@ */ package com.sap.cds.feature.ai; -import com.sap.cds.feature.ai.client.setup.AICoreSetup; +import com.sap.cds.feature.ai.client.setup.AICoreSetupHandler; import com.sap.cds.services.ServiceCatalog; -import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; @@ -27,28 +26,26 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { PersistenceService persistenceService = serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - logger.info("PersistenceService obtained from ServiceCatalog: " + (persistenceService != null)); - Optional bindingOpt = getAIBinding(runtime.getEnvironment()); + Optional binding = + runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> b.getServiceName().map(name -> name.equals("aicore")).orElse(false)) + .findFirst(); // If the AI Core service binding is present, create the AICoreSetup event handler to manage // resource groups for tenants. // The binding itself does *not* need to be passed to the AICoreSetup; the AICoreSetup uses // the com.sap.ai.sdk.core library which reads the binding directly from the environment // variable AICORE_SERVICE_KEY. - Optional setupOpt = bindingOpt.map(b -> new AICoreSetup(runtime.getEnvironment())); - setupOpt.ifPresent( - setup -> { - configurer.eventHandler(setup); + Optional setup = + binding.map(b -> new AICoreSetupHandler(runtime.getEnvironment())); + setup.ifPresent( + s -> { + configurer.eventHandler(s); logger.info("Registered AICoreSetup as event handler for MTX subscribe/unsubscribe."); }); - configurer.eventHandler(new FioriRecommendationHandler(setupOpt, persistenceService)); + configurer.eventHandler(new FioriRecommendationHandler(setup, persistenceService)); logger.info("Registered FioriRecommendationHandler for recommendations."); } - - private static Optional getAIBinding(CdsEnvironment environment) { - return environment - .getServiceBindings() - .filter(b -> b.getServiceName().map(name -> name.equals("aicore")).orElse(false)) - .findFirst(); - } } diff --git a/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java b/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java index 28d8b43..cf06052 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java @@ -6,7 +6,7 @@ import com.sap.cds.feature.ai.client.AIClient; import com.sap.cds.feature.ai.client.AICoreClient; import com.sap.cds.feature.ai.client.MockAIClient; -import com.sap.cds.feature.ai.client.setup.AICoreSetup; +import com.sap.cds.feature.ai.client.setup.AICoreSetupHandler; import com.sap.cds.ql.Select; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.services.cds.ApplicationService; @@ -49,7 +49,7 @@ public class FioriRecommendationHandler implements EventHandler { "IsActiveEntity", "DraftAdministrativeData_DraftUUID"); - public FioriRecommendationHandler(Optional setupOpt, PersistenceService db) { + public FioriRecommendationHandler(Optional setupOpt, PersistenceService db) { this.db = db; if (setupOpt.isPresent()) { logger.info("Registered AI Service Handler with AI Core setup."); diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java b/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java index b011adf..695c9ed 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java @@ -13,7 +13,7 @@ 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.feature.ai.client.setup.AICoreSetup; +import com.sap.cds.feature.ai.client.setup.AICoreSetupHandler; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import java.util.LinkedHashMap; @@ -26,7 +26,7 @@ public class AICoreClient implements AIClient { - private final AICoreSetup setup; + private final AICoreSetupHandler setup; private static final Logger logger = LoggerFactory.getLogger(AICoreClient.class); // Retry inference call when resource group inference endpoint is not yet ready (403) @@ -46,7 +46,7 @@ public class AICoreClient implements AIClient { "createdAt", "modifiedAt"); - public AICoreClient(AICoreSetup setup) { + public AICoreClient(AICoreSetupHandler setup) { this.setup = setup; } @@ -126,9 +126,14 @@ private List> predict( // the logic from RptClient using our per-tenant resource group, with the same arguments, i.e., // JacksonConfiguration.getDefaultObjectMapper() and the default header "Content-Encoding: // gzip". + System.out.println("Resolving inference destination for resource group: " + resourceGroup); + System.out.println( + "AICoreSetup.isMultitenancyEnabled() = " + AICoreSetupHandler.isMultitenancyEnabled()); var inferenceBuilder = new AiCoreService().getInferenceDestination(resourceGroup); + var model = RptModel.SAP_RPT_1_SMALL; + System.out.println("Using model: " + model.name() + ", deployment: " + inferenceBuilder); var destination = - AICoreSetup.isMultitenancyEnabled() + AICoreSetupHandler.isMultitenancyEnabled() ? inferenceBuilder.usingDeploymentId(setup.getDeploymentForResourceGroup(resourceGroup)) : inferenceBuilder.forModel(RptModel.SAP_RPT_1_SMALL); var apiClient = @@ -150,7 +155,7 @@ private List> predict( throw new RuntimeException("Failed to parse prediction response", e); } } catch (OpenApiRequestException e) { - if (AICoreSetup.notReadyYet(e) && i < INFERENCE_READY_MAX_RETRIES - 1) { + if (AICoreSetupHandler.notReadyYet(e) && i < INFERENCE_READY_MAX_RETRIES - 1) { logger.debug( "Inference endpoint for resource group {} not ready yet (403), retrying in {} ms ({}/{})", resourceGroup, diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandler.java similarity index 92% rename from srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java rename to srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandler.java index f88d1ea..2249167 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetup.java +++ b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandler.java @@ -36,7 +36,7 @@ import org.slf4j.LoggerFactory; @ServiceName(DeploymentService.DEFAULT_NAME) -public class AICoreSetup implements EventHandler { +public class AICoreSetupHandler implements EventHandler { private static final String DEFAULT_RESOURCE_GROUP = "default"; private static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; @@ -49,7 +49,7 @@ public class AICoreSetup implements EventHandler { // resource group private static final int AICORE_OPS_MAX_RETRIES = 10; private static final long AICORE_OPS_INITIAL_DELAY_MS = 300; - private static final Logger logger = LoggerFactory.getLogger(AICoreSetup.class); + private static final Logger logger = LoggerFactory.getLogger(AICoreSetupHandler.class); // In-memory cache: tenantId -> resourceGroupId private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); @@ -67,7 +67,7 @@ Map getResourceGroupDeploymentCache() { return resourceGroupDeploymentCache; } - public AICoreSetup(CdsEnvironment environment) { + public AICoreSetupHandler(CdsEnvironment environment) { this.environment = environment; } @@ -78,16 +78,14 @@ public AICoreSetup(CdsEnvironment environment) { @After(event = DeploymentService.EVENT_SUBSCRIBE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); - logger.info("Creating AI Core resources for tenant: {}", tenantId); + logger.debug("Creating AI Core resources for tenant {}", tenantId); try { String resourceGroupId = getResourceGroupForTenant(tenantId); - logger.info("AI Core resource group created: {} for tenant: {}", resourceGroupId, tenantId); + logger.info("Created AI Core resource group {} for tenant {}", resourceGroupId, tenantId); } catch (Exception e) { // Don't throw - let subscription succeed logger.error( - "Failed to create AI Core resources for tenant {} (retrying on demand)", - tenantId, - e); + "Failed to create AI Core resources for tenant {} (retrying on demand)", tenantId, e); } } @@ -98,14 +96,13 @@ public void afterSubscribe(SubscribeEventContext context) { @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); - logger.info("Deleting AI Core resources for tenant: {}", tenantId); + logger.debug("Deleting AI Core resources for tenant {}", tenantId); try { deleteResourceGroupForTenant(tenantId); - logger.info("AI Core resources deleted for tenant: {}", tenantId); + logger.info("Deleted AI Core resources for tenant {}", tenantId); } catch (Exception e) { // Don't throw - let unsubscription succeed - logger.warn( - "Failed to delete AI Core resources for tenant: {} - {}", tenantId, e.getMessage()); + logger.warn("Failed to delete AI Core resources for tenant {}: {}", tenantId, e.getMessage()); } } @@ -114,6 +111,7 @@ public static boolean isMultitenancyEnabled() { System.getProperty( "cds.multitenancy.enabled", System.getenv().getOrDefault("CDS_MULTITENANCY_ENABLED", "false"))); + // this.environment.getProperty("cds.requires.multitenancy", Boolean.class, false)); } /** @@ -128,7 +126,7 @@ public String resolveResourceGroup(String tenantId) { this.environment.getProperty( "cds.requires.AICore.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); group = group != null ? group : DEFAULT_RESOURCE_GROUP; - logger.info("Multitenancy disabled, using resource group: {}", group); + logger.info("Multitenancy disabled, using resource group {}", group); return group; } @@ -167,7 +165,7 @@ private String createResourceGroupForTenant(String tenantId, ResourceGroupApi ap .resourceGroupId(resourceGroupId) .labels(List.of(label)); api.create(request); - logger.info("Created resource group {} for tenant {}", resourceGroupId, tenantId); + logger.debug("Created resource group {} for tenant {}", resourceGroupId, tenantId); return resourceGroupId; } @@ -185,7 +183,7 @@ public String getDeploymentForResourceGroup(String resourceGroup) { // Look for an existing running or pending RPT-1 deployment in this resource group. AiDeploymentList deploymentList = queryDeploymentsFromResourceGroupUntilReady(deploymentApi, resourceGroup); - Optional existing = + Optional aiDeployment = deploymentList.getResources().stream() .filter( d -> @@ -193,8 +191,8 @@ public String getDeploymentForResourceGroup(String resourceGroup) { && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) || AiDeploymentStatus.PENDING.equals(d.getStatus()))) .findFirst(); - if (existing.isPresent()) { - String deploymentId = existing.get().getId(); + if (aiDeployment.isPresent()) { + String deploymentId = aiDeployment.get().getId(); resourceGroupDeploymentCache.put(resourceGroup, deploymentId); return deploymentId; } @@ -214,7 +212,7 @@ public String getDeploymentForResourceGroup(String resourceGroup) { String configId; if (existingConfig.isPresent()) { configId = existingConfig.get().getId(); - logger.info( + logger.debug( "Reusing existing RPT-1 configuration {} in resource group {}", configId, resourceGroup); } else { // Configuration creation is synchronous and should be fast, so we don't implement a retry @@ -229,7 +227,7 @@ public String getDeploymentForResourceGroup(String resourceGroup) { AiParameterArgumentBinding.create().key("modelName").value(RPT_MODEL_NAME), AiParameterArgumentBinding.create().key("modelVersion").value(RPT_MODEL_VERSION))); configId = configApi.create(resourceGroup, configRequest).getId(); - logger.info("Created RPT-1 configuration {} in resource group {}", configId, resourceGroup); + logger.debug("Created RPT-1 configuration {} in resource group {}", configId, resourceGroup); } // Now create a deployment for the configuration and poll until it's running and usable. @@ -239,7 +237,7 @@ public String getDeploymentForResourceGroup(String resourceGroup) { var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); var deployResponse = deploymentApi.create(resourceGroup, deployRequest); String deploymentId = deployResponse.getId(); - logger.info( + logger.debug( "Created RPT-1 deployment {} in resource group {}, polling for RUNNING", deploymentId, resourceGroup); @@ -364,7 +362,7 @@ public static boolean notReadyYet(OpenApiRequestException e) { private void deleteResourceGroupForTenant(String tenantId) { String resourceGroupId = tenantResourceGroupCache.remove(tenantId); if (resourceGroupId == null) { - logger.info("No cached resource group for tenant {}, nothing to delete", tenantId); + logger.debug("No cached resource group for tenant {}, nothing to delete", tenantId); return; } resourceGroupDeploymentCache.remove(resourceGroupId); diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java index ec574c9..bdf43a0 100644 --- a/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java +++ b/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java @@ -8,7 +8,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.ai.client.setup.AICoreSetup; +import com.sap.cds.feature.ai.client.setup.AICoreSetupHandler; import com.sap.cds.services.environment.CdsEnvironment; import java.net.URI; import java.net.URLEncoder; @@ -46,7 +46,7 @@ void setup() throws Exception { credentials = MAPPER.readValue(serviceKey, new TypeReference<>() {}); resourceGroup = "default"; CdsEnvironment environment = Mockito.mock(CdsEnvironment.class); - cut = new AICoreClient(new AICoreSetup(environment)); + cut = new AICoreClient(new AICoreSetupHandler(environment)); } /** diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java similarity index 98% rename from srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java rename to srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java index fde3add..3ecb546 100644 --- a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupTest.java +++ b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java @@ -34,14 +34,14 @@ * "clientid": "...", "clientsecret": "...", "url": "...", "serviceurls": { "AI_API_URL": "..." } } */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class AICoreSetupTest { +class AICoreSetupHandlerTest { private static final String TEST_TENANT = "it-test-tenant-" + System.currentTimeMillis(); private static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final Logger logger = LoggerFactory.getLogger(AICoreSetupTest.class); + private static final Logger logger = LoggerFactory.getLogger(AICoreSetupHandlerTest.class); - private AICoreSetup cut; + private AICoreSetupHandler cut; private AICoreClient client; private Map credentials; private String aiApiUrl; @@ -56,7 +56,7 @@ void setup() throws Exception { System.setProperty("cds.multitenancy.enabled", "true"); CdsEnvironment environment = mock(CdsEnvironment.class); - cut = new AICoreSetup(environment); + cut = new AICoreSetupHandler(environment); client = new AICoreClient(cut); } From 28143ce47fc2323ff68432663805f2aaccd1b663 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Fri, 1 May 2026 15:14:03 +0200 Subject: [PATCH 12/42] Remove .DS_Store --- .DS_Store | Bin 8196 -> 0 bytes .gitignore | 2 ++ samples/.DS_Store | Bin 6148 -> 0 bytes 3 files changed, 2 insertions(+) delete mode 100644 .DS_Store delete mode 100644 samples/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index db2fb2d3abd3d847795c3ee82d3d7ddd14b5d2b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMTWl0n7(U;$&>1GcPzw}f+^s7VOARd+5K&mRZRFB&+m>Fqtg|}EcpTsk%ntdVWnQQfZ`;B*>pJhgr`Eg+~CA z{0I;hYSTF&^of^bvLwfZ3|(nXae6?>6{RBvggfcu+?-^xB*%ml?hL}6p>$`IPACZP zPJZ#woFOS>R7V+zGB7m*oIX{|X9gQ&Lsy^Qy)0?YWQQEv51N|JL#n8pF|$gpl2v(s zdcYe_2S!lvQ$~I-?{#^elP$N6>>k@1&S*8Qo*NjpYvn@++qB4JUytpY-f+q*xTYWa zriDUQWF@05ICgAxQ%hsKd9*PuTI*WYj*cqw{KU%jyEBKKLDxGj!a!*$V6!KM_4w47 z9KYC_b$Cn_>2gdL$}uUkRCTuA)7#g-cV9-+3cj`5a*V((T5U#P`5A4lPa$r}dG5}< zRiw~*a-LHdbV)NWXE?b68C#niCv86@taEwKadvvX9oU}R*Be+z1Fqu}O;Gjr`(8e1 z4JYfhX?|f<&F`^eh&%gxth`TBQBKWS7`tQnU8~mKmu%nCx$E3qZC6v^+Gt}sHdc7>~<>Re+i)t~fHt3CtX2@MWg)F96sWvN` zuXG)zS1(*!)HSkZD&3BewHz_DPF*i++mxQ7h*%8Ps2gRaH|GrwQZT+4OX+R0_N9D) z-?+o@gB?SbTYjXJzFC(0{Q)Cy?HDHDl|4FSIed*Y&RyC~JMi*hHn!L4dLN%^lWAHe z1--a-QMI1dyJhVx`DnIVvX;~WP@lXKDJ&9TVak9$tE#eBgA;kw>X_O zu@u|N4iMQ!*a`M5dzHP%PO~%YGxiNT&wgOPuwU74>@vH;{zN5ap$1E^6!ln+JFyCP zV>Q;G6&>ircJw0$69;h!4hksZC`Rxw9>Z}wffINJFW^PIj8||HZ{cmcgOBhDKE>zw z0_X7~F5)L#!ev~+ACe+fOG_kOx=pH=mP-xN3TcD1QQ9o+lKQ2LG$3M-%7oiL9#hzm zMo3IrM2K+o5s(v3I(2Qr#U9waW$U(%8L+j|M16>i$%qwPRAb#l3+w1~l+>y?edTl9y6$#0rp+MYi0-m_>dqw6i0+EU zSWJ_MtD@V~98n?0ZGDl8`=rqJFy#SbYVX-Fpwp*J8&_CBgo?zM)3&7@F-#Z zNj!z8@f@BfyuU<1`oo>=zg?R2-e*SNt{`>z;8eR0*Q3j$6Tu%m2*_Ligk>zx&n?$-eT@TX53%45+ wGIXJeoAB}QI8Ji>4?}9lOS)u9jtNN`D*yV2fb0Fi9qs?o{x1&1|H=LTZ(9m=C;$Ke diff --git a/.gitignore b/.gitignore index a057d33..8775721 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ hs_err* .vscode .idea .reloadtrigger + +**/.DS_Store diff --git a/samples/.DS_Store b/samples/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 Date: Fri, 1 May 2026 15:14:35 +0200 Subject: [PATCH 13/42] Adjust Readme: aicore handling availabe with first version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f83456e..ac311c6 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ annotate Books with { } ``` -### 2. Use case: Simplified AI Core usage (supported from version 0.0.2) +### 2. Use case: Simplified AI Core usage The plugin introduces an `AICore` CAP service that automatically performs some administrative tasks and offers simplified access to AI Core. From ca15eabf856b0e1147f71e9caf64612f4badea5c Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Sat, 2 May 2026 12:03:09 +0200 Subject: [PATCH 14/42] Use the binding and not the environment variable for the integration test --- .gitignore | 2 ++ README.md | 9 ++++----- .../client/setup/AICoreSetupHandlerTest.java | 18 +++++++++++++----- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 8775721..08ae9e3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ hs_err* .reloadtrigger **/.DS_Store + +.cdsrc-private.json diff --git a/README.md b/README.md index ac311c6..97e8420 100644 --- a/README.md +++ b/README.md @@ -128,13 +128,12 @@ or for a full build including tests: mvn clean install ``` -To run integration tests against a real AI Core instance, set the following environment variable, then run `mvn test`: +To run the integration test [AICoreSetupHandlerTest](https://github.com/cap-java/cds-feature-ai/blob/main/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java), you need a [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding. +Then, first run `cds bind -to ` in order to make the service binding available locally. This command will use your currently targeted Cloud Foundry space, for more info consult the cds bind documentation at https://cap.cloud.sap/docs/tools/cds-bind. -| Variable | Description | -|---|---| -| `AICORE_SERVICE_KEY` | Full AI Core service key JSON: `{ "clientid": "...", "clientsecret": "...", "url": "...", "serviceurls": { "AI_API_URL": "..." } }` | +Then execute the test with `cds bind --exec mvn test`. -If the variable is not set, the integration tests are skipped automatically. +If there is no binding, the integration test is skipped automatically. ## Support, Feedback, Contributing diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java index 3ecb546..5d40fd7 100644 --- a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java +++ b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java @@ -29,9 +29,7 @@ /** * Integration test verifying the full AICoreSetup lifecycle against a real AI Core instance. - * - *

Required environment variables: AICORE_SERVICE_KEY – Full AI Core service key JSON: { - * "clientid": "...", "clientsecret": "...", "url": "...", "serviceurls": { "AI_API_URL": "..." } } + * Requires an AI Core service instance bound to the app (VCAP_SERVICES). */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AICoreSetupHandlerTest { @@ -48,8 +46,18 @@ class AICoreSetupHandlerTest { @BeforeAll void setup() throws Exception { - String serviceKey = System.getenv("AICORE_SERVICE_KEY"); - assumeTrue(serviceKey != null, "Skipping integration test: AICORE_SERVICE_KEY env var not set"); + String vcap = System.getenv("VCAP_SERVICES"); + String serviceKey = null; + if (vcap != null) { + Map vcapServices = MAPPER.readValue(vcap, new TypeReference<>() {}); + List> aicoreBindings = + MAPPER.convertValue(vcapServices.get("aicore"), new TypeReference<>() {}); + if (aicoreBindings != null && !aicoreBindings.isEmpty()) { + serviceKey = MAPPER.writeValueAsString(aicoreBindings.get(0).get("credentials")); + } + } + assumeTrue( + serviceKey != null, "Skipping integration test: VCAP_SERVICES with aicore binding not set"); credentials = MAPPER.readValue(serviceKey, new TypeReference<>() {}); aiApiUrl = ((Map) credentials.get("serviceurls")).get("AI_API_URL").toString(); From 29dedcc7184de4665dc4f7bd65ea099f5303377c Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 6 May 2026 16:17:24 +0200 Subject: [PATCH 15/42] Add blackduck and codeql action --- .github/actions/build/action.yml | 40 ---- .../actions/scan-with-blackduck/action.yml | 38 ++-- .github/actions/scan-with-codeql/action.yml | 49 +++++ .github/actions/scan-with-sonar/action.yml | 75 -------- .github/workflows/main.yml | 17 +- .github/workflows/pipeline.yml | 175 ------------------ .github/workflows/pr.yml | 31 ---- 7 files changed, 84 insertions(+), 341 deletions(-) delete mode 100644 .github/actions/build/action.yml create mode 100644 .github/actions/scan-with-codeql/action.yml delete mode 100644 .github/actions/scan-with-sonar/action.yml delete mode 100644 .github/workflows/pipeline.yml delete mode 100644 .github/workflows/pr.yml diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml deleted file mode 100644 index 26dd1b9..0000000 --- a/.github/actions/build/action.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Maven Build -description: Builds a Maven project. - -inputs: - java-version: - description: The Java version the build will run with. - required: true - maven-version: - description: The Maven version the build will run with. - required: true - mutation-testing: - description: Whether to run mutation testing or not. - default: 'true' - required: false - -runs: - using: composite - steps: - - name: Set up Java ${{ inputs.java-version }} - uses: actions/setup-java@v5 - with: - java-version: ${{ inputs.java-version }} - distribution: sapmachine - cache: maven - - - name: Set up Maven ${{ inputs.maven-version }} - uses: stCarolas/setup-maven@v5 - with: - maven-version: ${{ inputs.maven-version }} - - - name: Piper Maven build - uses: SAP/project-piper-action@main - with: - step-name: mavenBuild - docker-image: '' - - - name: Mutation Testing - if: ${{ inputs.mutation-testing == 'true' }} - run: mvn org.pitest:pitest-maven:mutationCoverage -f cds-feature-ai/pom.xml -ntp -B - shell: bash diff --git a/.github/actions/scan-with-blackduck/action.yml b/.github/actions/scan-with-blackduck/action.yml index 7ac1a58..8b23ade 100644 --- a/.github/actions/scan-with-blackduck/action.yml +++ b/.github/actions/scan-with-blackduck/action.yml @@ -17,7 +17,7 @@ inputs: required: true scan_mode: description: The scan mode to use (FULL or RAPID) - default: 'FULL' + default: 'RAPID' required: false runs: @@ -41,23 +41,23 @@ runs: echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT shell: bash - - name: BlackDuck Detect Scan - run: | - bash <(curl -s -L https://detect.synopsys.com/detect9.sh) \ - --blackduck.url=https://sap.blackducksoftware.com/ \ - --blackduck.api.token="${BLACKDUCK_TOKEN}" \ - --detect.project.name=com.sap.cds.feature.ai \ - --detect.project.version.name="${REVISION}" \ - --detect.included.detector.types=MAVEN \ - --detect.excluded.directories='**/node_modules,**/*test*,**/localrepo,**/target/site,**/*-site.jar,**/samples/**' \ - --detect.maven.excluded.modules=integration-tests,integration-tests/db,integration-tests/generic,integration-tests/mtx-local/srv \ - --detect.maven.build.command='-pl com.sap.cds:cds-feature-ai' \ - --detect.tools=DETECTOR,BINARY_SCAN \ - --detect.blackduck.scan.mode="${SCAN_MODE}" \ - --detect.risk.report.pdf=false \ + - name: BlackDuck Security Scan + uses: blackduck-inc/black-duck-security-scan@659a0742e793a093377fab3117b0d90f23b04bfa # v2.9.0 + with: + blackducksca_url: https://sap.blackducksoftware.com/ + blackducksca_token: ${{ inputs.blackduck_token }} + blackducksca_scan_full: ${{ inputs.scan_mode == 'FULL' }} + github_token: ${{ inputs.github_token }} + detect_args: > + --detect.project.name=com.sap.cds.feature.ai + --detect.project.version.name=${{ steps.get-revision.outputs.REVISION }} + --detect.included.detector.types=MAVEN + --detect.excluded.directories=**/node_modules,**/*test*,**/localrepo,**/target/site,**/*-site.jar,**/samples/** + --detect.tools=DETECTOR,BINARY_SCAN + --detect.risk.report.pdf=false --logging.level.detect=INFO - shell: bash env: - BLACKDUCK_TOKEN: ${{ inputs.blackduck_token }} - SCAN_MODE: ${{ inputs.scan_mode }} - REVISION: ${{ steps.get-revision.outputs.REVISION }} + BLACKDUCKSCA_TOKEN: ${{ inputs.blackduck_token }} + BLACKDUCKSCA_URL: https://sap.blackducksoftware.com/ + BLACKDUCK_API_TOKEN: ${{ inputs.blackduck_token }} + DETECT_MAVEN_BUILD_COMMAND: '-pl com.sap.cds:cds-feature-ai' diff --git a/.github/actions/scan-with-codeql/action.yml b/.github/actions/scan-with-codeql/action.yml new file mode 100644 index 0000000..bdaca32 --- /dev/null +++ b/.github/actions/scan-with-codeql/action.yml @@ -0,0 +1,49 @@ +name: Scan with CodeQL +description: Scans the project with CodeQL + +inputs: + java-version: + description: The Java version to use for the build. + required: true + maven-version: + description: The Maven version to use for the build. + 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: Initialize CodeQL + uses: github/codeql-action/init@ed410739ba306e4ebe5e123421a6bd694e494a2b # v4 + with: + languages: java-kotlin + build-mode: manual + queries: security-extended + + - name: Install @sap/cds-dk + run: npm i -g @sap/cds-dk + shell: bash + + - name: Install npm dependencies + run: npm install + shell: bash + + - name: Build Java code + run: mvn clean compile -B -ntp -Dcds.install-node.skip + shell: bash + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@ed410739ba306e4ebe5e123421a6bd694e494a2b # v4 + with: + category: "/language:java-kotlin" diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml deleted file mode 100644 index 22df515..0000000 --- a/.github/actions/scan-with-sonar/action.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Scan with SonarQube -description: Scans the project with SonarQube - -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@v5 - with: - java-version: ${{inputs.java-version}} - distribution: sapmachine - cache: maven - - - name: Set up Maven ${{inputs.maven-version}} - uses: stCarolas/setup-maven@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: Print Revision - run: echo "${{steps.get-revision.outputs.REVISION}}" - shell: bash - - - name: Build project for SonarQube scan - run: | - mvn clean verify -ntp -B - shell: bash - - - name: Verify JaCoCo reports exist - run: | - echo "=== Checking JaCoCo reports ===" - find . -name "jacoco.xml" -type f - if [ -f "cds-feature-ai/target/site/jacoco/jacoco.xml" ]; then - echo "Found: cds-feature-ai/target/site/jacoco/jacoco.xml" - else - echo "Missing: cds-feature-ai/target/site/jacoco/jacoco.xml" - fi - 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 - uses: SAP/project-piper-action@main - with: - step-name: sonarExecuteScan - flags: > - --token=${{ inputs.sonarq-token }} - --githubToken=${{ inputs.github-token }} - --version=${{ steps.get-revision.outputs.REVISION }} - --inferJavaBinaries=true - --options=-Dsonar.exclusions=**/samples/**,-Dsonar.coverage.jacoco.xmlReportPaths=coverage-report/target/site/jacoco-aggregate/jacoco.xml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6ba3af4..63b8bbf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ permissions: packages: read env: - MAVEN_VERSION: '3.9.12' + MAVEN_VERSION: '3.9.15' on: workflow_dispatch: @@ -30,3 +30,18 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} maven-version: ${{ env.MAVEN_VERSION }} scan_mode: RAPID + + codeql: + name: CodeQL Scan + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Scan With CodeQL + continue-on-error: true + uses: ./.github/actions/scan-with-codeql + with: + maven-version: ${{ env.MAVEN_VERSION }} + java-version: '17' diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml deleted file mode 100644 index 5abb402..0000000 --- a/.github/workflows/pipeline.yml +++ /dev/null @@ -1,175 +0,0 @@ -name: Reusable Workflow - -env: - MAVEN_VERSION: '3.9.12' - -on: - workflow_call: - inputs: - deploy-snapshot: - required: true - type: boolean - default: false - -jobs: - build: - name: Build (Java ${{ matrix.java-version }}) - runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - matrix: - java-version: [ 17, 21 ] - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # For internal PRs (same repo), checkout PR head to test actual changes - # For external PRs (forks), checkout base branch for security - ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} - - - name: Spotless check - run: mvn spotless:check -Dspotless.check.skip=false - - - name: Build - uses: ./.github/actions/build - with: - java-version: ${{ matrix.java-version }} - maven-version: ${{ env.MAVEN_VERSION }} - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: build-artifacts-java-${{ matrix.java-version }} - path: | - **/target/*.jar - **/pom.xml - .mvn/ - retention-days: 1 - - integration-tests: - name: Integration Tests (Java ${{ matrix.java-version }}, ${{ matrix.test-type }}) - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: build - #env: - # cf login for tests against aicore - strategy: - fail-fast: false - matrix: - java-version: [ 17, 21 ] - test-type: [ build-version, latest-version, oss ] - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} - - - name: Download build artifacts - uses: actions/download-artifact@v8 - with: - name: build-artifacts-java-${{ matrix.java-version }} - - sonarqube-scan: - name: SonarQube Scan - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: build - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} - - 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.GH_TOKEN }} - - codeql: - name: CodeQL Analysis - runs-on: ubuntu-latest - needs: build - timeout-minutes: 30 - permissions: - security-events: write - packages: read - actions: read - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} - - - name: Set up Java - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: 'sapmachine' - cache: 'maven' - - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: java-kotlin - build-mode: manual - - - name: Build Java code - run: mvn clean compile -DskipTests -B -ntp - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:java-kotlin" - - deploy-snapshot: - name: Deploy snapshot to Artifactory - runs-on: ubuntu-latest - timeout-minutes: 30 - if: ${{ inputs.deploy-snapshot == true }} - needs: [build, integration-tests, codeql] - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.sha }} - - - name: Set up Java - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: 'sapmachine' - cache: 'maven' - server-id: artifactory - server-username: DEPLOYMENT_USER - server-password: DEPLOYMENT_PASS - - - name: Set up Maven ${{ env.MAVEN_VERSION }} - uses: stCarolas/setup-maven@v5 - with: - maven-version: ${{ env.MAVEN_VERSION }} - - - name: Set Dry Run for Pull Request - if: github.event_name == 'pull_request_target' - run: echo "DRY_RUN_PARAM=-DaltDeploymentRepository=local-repo::default::file:./local-repo" >> $GITHUB_ENV - shell: bash - - - name: Get Revision - id: get-revision - run: | - echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT - shell: bash - - - name: Print Revision - run: echo "Current revision ${{ steps.get-revision.outputs.REVISION }}" - shell: bash - - - name: Deploy snapshot - if: ${{ endsWith(steps.get-revision.outputs.REVISION, '-SNAPSHOT') }} - run: mvn -B -ntp -fae -pl !integration-tests,!integration-tests/db,!integration-tests/generic,!integration-tests/mtx-local/srv -Dmaven.install.skip=true -Dmaven.test.skip=true -DdeployAtEnd=true deploy - env: - DEPLOYMENT_USER: ${{ secrets.DEPLOYMENT_USER }} - DEPLOYMENT_PASS: ${{ secrets.DEPLOYMENT_PASS }} - shell: bash diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml deleted file mode 100644 index c1ee2a1..0000000 --- a/.github/workflows/pr.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: CI - PR - -on: - workflow_dispatch: - pull_request_target: - branches: [main] - types: [reopened, synchronize, opened] - -permissions: - contents: read - actions: read - security-events: write - packages: read - -jobs: - requires-approval: - runs-on: ubuntu-latest - name: "Waiting for PR approval as this workflow runs on pull_request_target" - if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.owner.login != 'cap-java' - environment: pr-approval - steps: - - name: Approval Step - run: echo "This job has been approved!" - - build-and-test: - needs: requires-approval - if: always() && (needs.requires-approval.result == 'success' || needs.requires-approval.result == 'skipped') - uses: ./.github/workflows/pipeline.yml - with: - deploy-snapshot: false - secrets: inherit From edb792a183469d3caf7a1b903c7d203989b8efac Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 12 May 2026 17:44:53 +0200 Subject: [PATCH 16/42] Version bumps --- pom.xml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index ca0e387..0be7f13 100644 --- a/pom.xml +++ b/pom.xml @@ -53,8 +53,8 @@ 17 - 4.6.1 - 3.5.6 + 4.9.0 + 3.5.14 1.18.0 0.8.14 6.0.3 @@ -79,6 +79,15 @@ + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + com.sap.cds cds-services-bom @@ -116,6 +125,13 @@ cds-feature-ai ${revision} + + + + org.bouncycastle + bcprov-jdk18on + 1.84 + From 0ff1ff0e4e5aec35825c577943079a9dde2b6021 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Fri, 22 May 2026 11:48:50 +0200 Subject: [PATCH 17/42] Migrate codebase from internal fork (multi-module structure) (#18) Replaces the single-module srv/ codebase with the multi-module Maven layout developed on the internal SAP fork. Adds LICENSE at root (per OSPO finding), pom.xml SCM section, and fixes the broken .reuse/dep5 link in CONTRIBUTING.md. The .github/ workflows and gh_ruleset.json from this repo are preserved. Note: integration-tests/spring tests fail on clean clone because the build does not invoke npm install before cds build, so the @cap-js/ai node plugin is not available when the csn is generated. Pre-existing issue on the fork's main; tracked separately. --- .gitignore | 1 + CONTRIBUTING.md | 2 +- LICENSE | 201 +++++++++ README.md | 126 ++---- cds-feature-ai-core/README.md | 102 +++++ cds-feature-ai-core/pom.xml | 69 +++ .../feature/aicore/core/AICoreService.java | 39 ++ .../core/AICoreServiceConfiguration.java | 97 +++++ .../aicore/core/AICoreServiceImpl.java | 381 +++++++++++++++++ .../aicore/core/AICoreSetupHandler.java | 115 +++++ .../aicore/core/MockAICoreServiceImpl.java | 113 +++++ .../aicore/core/ModelDeploymentSpec.java | 16 + .../AICoreApplicationServiceHandler.java | 103 +++++ .../core/handler/AbstractCrudHandler.java | 40 ++ .../aicore/core/handler/ActionHandler.java | 61 +++ .../core/handler/ConfigurationHandler.java | 151 +++++++ .../core/handler/DeploymentHandler.java | 236 +++++++++++ .../core/handler/MockEntityHandler.java | 186 ++++++++ .../core/handler/ResourceGroupHandler.java | 198 +++++++++ ...s.services.runtime.CdsRuntimeConfiguration | 1 + .../resources/cds/com.sap.cds/ai/index.cds | 138 ++++++ .../resources/spotbugs-exclusion-filter.xml | 0 .../aicore/core/AICoreServiceImplTest.java | 172 ++++++++ .../aicore/core/AICoreSetupHandlerTest.java | 179 ++++++++ .../handler/ConfigurationHandlerTest.java | 69 +++ .../core/handler/DeploymentHandlerTest.java | 69 +++ .../handler/ResourceGroupHandlerTest.java | 127 ++++++ cds-feature-recommendations/README.md | 169 ++++++++ cds-feature-recommendations/pom.xml | 130 ++++++ .../FioriRecommendationHandler.java | 401 ++++++++++++++++++ .../MockRecommendationClient.java | 32 +- .../recommendation/RecommendationClient.java | 12 + .../RecommendationClientResolver.java | 12 + .../RecommendationConfiguration.java | 46 ++ .../recommendation/RptInferenceClient.java | 101 +++++ .../feature/recommendation/RptModelSpec.java | 43 ++ ...s.services.runtime.CdsRuntimeConfiguration | 1 + .../resources/spotbugs-exclusion-filter.xml | 24 ++ .../FioriRecommendationHandlerTest.java | 348 +++++++++++++++ .../RecommendationConfigurationTest.java | 48 +++ .../resources/model/recommendations-test.cds | 56 +++ cds-starter-ai/pom.xml | 30 ++ coverage-report/pom.xml | 210 +++++++++ integration-tests/.cdsrc.json | 13 + integration-tests/README.md | 78 ++++ integration-tests/db/schema.cds | 45 ++ integration-tests/mtx-local/.cdsrc.json | 14 + integration-tests/mtx-local/.gitignore | 5 + integration-tests/mtx-local/db/schema.cds | 8 + .../mtx-local/mtx/sidecar/package.json | 30 ++ integration-tests/mtx-local/package.json | 11 + integration-tests/mtx-local/srv/pom.xml | 255 +++++++++++ integration-tests/mtx-local/srv/service.cds | 6 + .../feature/aicore/itest/mt/Application.java | 15 + .../srv/src/main/resources/application.yaml | 23 + .../aicore/itest/mt/MtxLifecycleTest.java | 89 ++++ .../itest/mt/SubscribeUnsubscribeTest.java | 101 +++++ .../aicore/itest/mt/TenantIsolationTest.java | 103 +++++ .../mt/utils/SubscriptionEndpointClient.java | 68 +++ integration-tests/package.json | 12 + integration-tests/pom.xml | 28 ++ integration-tests/spring/pom.xml | 128 ++++++ .../java/com/sap/cds/feature/Application.java | 15 + .../src/main/resources/application.yaml | 24 ++ .../resources/spotbugs-exclusion-filter.xml | 9 + .../aicore/itest/AICoreServiceTest.java | 83 ++++ .../cds/feature/aicore/itest/ActionTest.java | 122 ++++++ .../ApplicationServiceDelegationTest.java | 41 ++ .../aicore/itest/BaseIntegrationTest.java | 68 +++ .../aicore/itest/ConfigurationTest.java | 134 ++++++ .../feature/aicore/itest/DeploymentTest.java | 98 +++++ .../aicore/itest/MultiTenancyTest.java | 97 +++++ .../cds/feature/aicore/itest/ODataTest.java | 42 ++ .../itest/ResourceGroupCleanupExtension.java | 64 +++ .../aicore/itest/ResourceGroupTest.java | 146 +++++++ .../NonStandardKeyRecommendationTest.java | 165 +++++++ .../itest/RecommendationTest.java | 362 ++++++++++++++++ integration-tests/spring/test-service.cds | 41 ++ package.json | 16 - pom.xml | 135 ++++-- samples/bookshop/.cdsrc.json | 6 + samples/bookshop/app/xs-app.json | 19 - samples/bookshop/db/src/.hdiconfig | 139 ------ samples/bookshop/db/undeploy.json | 7 - samples/bookshop/package.json | 3 +- samples/bookshop/pom.xml | 321 +++++++------- samples/bookshop/srv/admin-service.cds | 4 + samples/bookshop/srv/ai-core-service.cds | 30 ++ samples/bookshop/srv/pom.xml | 338 +++++++-------- .../java/customer/bookshop/Application.java | 7 +- .../handlers/AICoreShowcaseHandler.java | 171 ++++++++ .../handlers/CatalogServiceHandler.java | 81 ++-- .../srv/src/main/resources/application.yaml | 7 +- .../customer/bookshop/ApplicationTest.java | 2 +- .../handlers/CatalogServiceHandlerTest.java | 59 ++- srv/pom.xml | 218 ---------- .../feature/ai/AIRuntimeConfiguration.java | 51 --- .../ai/FioriRecommendationHandler.java | 332 --------------- .../sap/cds/feature/ai/client/AIClient.java | 20 - .../cds/feature/ai/client/AICoreClient.java | 179 -------- .../ai/client/setup/AICoreSetupHandler.java | 372 ---------------- ...s.services.runtime.CdsRuntimeConfiguration | 1 - .../ai/FioriRecommendationHandlerTest.java | 299 ------------- .../ai/client/AICoreClientPredictionTest.java | 232 ---------- .../feature/ai/client/MockAIClientTest.java | 146 ------- .../client/setup/AICoreSetupHandlerTest.java | 216 ---------- 106 files changed, 7818 insertions(+), 2791 deletions(-) create mode 100644 LICENSE create mode 100644 cds-feature-ai-core/README.md create mode 100644 cds-feature-ai-core/pom.xml create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java create mode 100644 cds-feature-ai-core/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration create mode 100644 cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds rename {srv => cds-feature-ai-core}/src/main/resources/spotbugs-exclusion-filter.xml (100%) create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java create mode 100644 cds-feature-recommendations/README.md create mode 100644 cds-feature-recommendations/pom.xml create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java rename srv/src/main/java/com/sap/cds/feature/ai/client/MockAIClient.java => cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java (51%) create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java create mode 100644 cds-feature-recommendations/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration create mode 100644 cds-feature-recommendations/src/main/resources/spotbugs-exclusion-filter.xml create mode 100644 cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java create mode 100644 cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java create mode 100644 cds-feature-recommendations/src/test/resources/model/recommendations-test.cds create mode 100644 cds-starter-ai/pom.xml create mode 100644 coverage-report/pom.xml create mode 100644 integration-tests/.cdsrc.json create mode 100644 integration-tests/README.md create mode 100644 integration-tests/db/schema.cds create mode 100644 integration-tests/mtx-local/.cdsrc.json create mode 100644 integration-tests/mtx-local/.gitignore create mode 100644 integration-tests/mtx-local/db/schema.cds create mode 100644 integration-tests/mtx-local/mtx/sidecar/package.json create mode 100644 integration-tests/mtx-local/package.json create mode 100644 integration-tests/mtx-local/srv/pom.xml create mode 100644 integration-tests/mtx-local/srv/service.cds create mode 100644 integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/aicore/itest/mt/Application.java create mode 100644 integration-tests/mtx-local/srv/src/main/resources/application.yaml create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/utils/SubscriptionEndpointClient.java create mode 100644 integration-tests/package.json create mode 100644 integration-tests/pom.xml create mode 100644 integration-tests/spring/pom.xml create mode 100644 integration-tests/spring/src/main/java/com/sap/cds/feature/Application.java create mode 100644 integration-tests/spring/src/main/resources/application.yaml create mode 100644 integration-tests/spring/src/main/resources/spotbugs-exclusion-filter.xml create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ODataTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupCleanupExtension.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/NonStandardKeyRecommendationTest.java create mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/RecommendationTest.java create mode 100644 integration-tests/spring/test-service.cds delete mode 100644 package.json delete mode 100644 samples/bookshop/app/xs-app.json delete mode 100644 samples/bookshop/db/src/.hdiconfig delete mode 100644 samples/bookshop/db/undeploy.json create mode 100644 samples/bookshop/srv/ai-core-service.cds create mode 100644 samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java delete mode 100644 srv/pom.xml delete mode 100644 srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java delete mode 100644 srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java delete mode 100644 srv/src/main/java/com/sap/cds/feature/ai/client/AIClient.java delete mode 100644 srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java delete mode 100644 srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandler.java delete mode 100644 srv/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration delete mode 100644 srv/src/test/java/com/sap/cds/feature/ai/FioriRecommendationHandlerTest.java delete mode 100644 srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java delete mode 100644 srv/src/test/java/com/sap/cds/feature/ai/client/MockAIClientTest.java delete mode 100644 srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java diff --git a/.gitignore b/.gitignore index 08ae9e3..1126c78 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ **/gen/ **/edmx/ +**/src/test/resources/model/csn.json *.db *.sqlite *.sqlite-wal diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 017fbe5..9766894 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ 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 contacting [a project maintainer](.reuse/dep5). +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by [opening an issue](https://github.com/cap-java/cds-feature-ai/issues) or by contacting one of the project maintainers listed in the repository. ## Engaging in Our Project 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/README.md b/README.md index 97e8420..bcc88d5 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,46 @@ -# SAP Cloud Application Programming Model, AI plugin for Java - -This is the Java version of the [SAP CAP AI plugin for Node.js](https://github.com/cap-js/ai). +# SAP Cloud Application Programming Model - AI Plugins for Java ## About this project -The SAP Cloud Application Programming Model, AI plugin for Java provides AI-powered UI recommendations for CAP Java applications, leveraging SAP AI Core and the SAP-RPT-1 model. - -> [!IMPORTANT] -> In multi-tenancy scenarios with a sidecar, the plugin must be included in the sidecar for SAP AI Core handling. - -### 1. Use case: Recommendations - -Recommendations are implemented leveraging SAP-RPT-1 and AI Core. This plugin generically hooks into any entity which has properties with a value help (detected via `@Common.ValueList` on the property or `@cds.odata.valuelist` on the association target). - -```cds -entity Books { - key ID : Integer; - title : String(111); - descr : String(1111); - genre : Association to one Genres; - status : Association to one Status; -} -annotate Genres with @cds.odata.valuelist; -annotate Books with { - status @Common.ValueList : { - CollectionPath : 'Status', - Parameters: [ - { - $Type: 'Common.ValueListParameterInOut' - ValueListProperty : 'code', - LocalDataProperty : status_code - } - ] - } -} -``` - -The annotated fields automatically receive AI-powered recommendations in Fiori draft edit mode. The handler fetches existing rows from the database as training context, calls the RPT-1 model, and writes the predictions into the `SAP_Recommendations` property on the result. - -If you do not want recommendations for a specific field, annotate it with `@UI.RecommendationState: 0`. - -```cds -annotate Books with { - genre @UI.RecommendationState : 0; -} -``` - -### 2. Use case: Simplified AI Core usage - -The plugin introduces an `AICore` CAP service that automatically performs some administrative tasks and offers simplified access to AI Core. +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. -#### Automatic operations +### Plugins -- The plugin automatically creates a new SAP AI Core resource group per tenant during tenant onboarding and deletes it during offboarding. -- The plugin automatically creates an RPT-1 deployment per resource group for the recommendations feature. +| 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 | -## Requirements and Setup +### Starter -### Prerequisites +For the simplest setup, add the **`cds-starter-ai`** dependency which bundles both plugins: -- Java 17+ -- Maven 3.6.3+ -- Node.js 20+ (for CDS build tooling) -- `@sap/cds-dk` 9+ (CDS build tooling) -- An [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding (for production) - -### Dependencies to add - -pom.xml: ```xml com.sap.cds - cds-feature-ai - ${revision} + cds-starter-ai + ${cds-ai.version} ``` -package.json: ```json "dependencies": { - "@cap-js/ai": "1.0.0" + "@cap-js/ai": "^1" } ``` -### AI Core service binding - -To use the plugin in production scenarios you need an [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding. -The plugin will automatically create resource groups per tenant labeled with `ext.ai.sap.com/CDS_TENANT_ID` in multi-tenancy scenarios and create an RPT-1 deployment in each for the recommendations feature. For multitenancy, set `cds.multitenancy.enabled=true` (or the environment variable `CDS_MULTITENANCY_ENABLED=true`). - -In single-tenant setups the plugin uses the 'default' resource group and creates an RPT-1 deployment as well if none exists. - -For single-tenant deployments you can change the resource group as follow in the `application.yaml`: - -```yaml -# application.yaml -cds: - requires: - AICore: - resourceGroup: CUSTOM_RESOURCE_GROUP -``` +## Prerequisites -For Cloud Foundry apps an example config could look like in [samples/bookshop/mta.yaml](samples/bookshop/mta.yaml). - -For local development without an AI Core binding, the plugin falls back to a `MockAIClient` that returns random predictions from the existing context rows. +- 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. -## Test the plugin locally +## Samples -In `samples/bookshop` you can find a complete CAP Java bookshop that demonstrates the plugin: +In [`samples/bookshop`](samples/bookshop) you can find a complete CAP Java bookshop demonstrating both plugins: ```bash mvn clean install @@ -115,26 +48,20 @@ cd samples/bookshop mvn spring-boot:run ``` -### Local Testing -To execute local tests, simply run: +## Local Development ```bash -mvn test +mvn clean install # build all modules +mvn test # run unit tests ``` -or for a full build including tests: +For integration tests against a real AI Core instance: ```bash -mvn clean install +cds bind ai-core -2 +cds bind --exec mvn test -pl integration-tests/spring -am ``` -To run the integration test [AICoreSetupHandlerTest](https://github.com/cap-java/cds-feature-ai/blob/main/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java), you need a [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding. -Then, first run `cds bind -to ` in order to make the service binding available locally. This command will use your currently targeted Cloud Foundry space, for more info consult the cds bind documentation at https://cap.cloud.sap/docs/tools/cds-bind. - -Then execute the test with `cds bind --exec mvn test`. - -If there is no binding, the integration test is skipped automatically. - ## Support, Feedback, Contributing This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-java/cds-feature-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). @@ -150,4 +77,3 @@ We as members, contributors, and leaders pledge to make participation in our com ## Licensing Copyright 2026 SAP SE or an SAP affiliate company and cds-feature-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/cds-feature-ai-core/README.md b/cds-feature-ai-core/README.md new file mode 100644 index 0000000..d8229f7 --- /dev/null +++ b/cds-feature-ai-core/README.md @@ -0,0 +1,102 @@ +# 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 +- **RPT-1 Deployment Management** - Auto-creates configurations and deployments for the SAP-RPT-1 model +- **Prediction Client** - `AIClient` interface for calling the RPT-1 prediction API with retry and backoff +- **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.requires.AICore` namespace in `application.yaml`: + +```yaml +cds: + requires: + AICore: + resourceGroup: default # Resource group for single-tenant mode + resourceGroupPrefix: "cds-" # Prefix for auto-created tenant resource groups + multiTenancy: false # Enable per-tenant resource groups + maxRetries: 10 # Max retry attempts for transient AI Core errors + initialDelayMs: 300 # Initial backoff delay (ms) +``` + +## 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 | + +### Functions & Actions + +```java +// Get the resource group ID for a CDS tenant +String rgId = aiCoreService.resourceGroupForTenant(tenantId); + +// Get (or auto-create) the RPT-1 deployment ID for a resource group +String deploymentId = aiCoreService.rpt1DeploymentId(resourceGroupId); +``` + +## Multi-Tenancy + +When `cds.requires.AICore.multiTenancy=true`: + +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")); + +// Use the prediction client +AIClient client = new AICoreClient(aiCore, runtime); +List predictions = client.fetchPredictions(rows, targetColumns, indexColumn); +``` + +## 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/pom.xml b/cds-feature-ai-core/pom.xml new file mode 100644 index 0000000..b80bac3 --- /dev/null +++ b/cds-feature-ai-core/pom.xml @@ -0,0 +1,69 @@ + + + 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} + + + org.jacoco + jacoco-maven-plugin + + + jacoco-initialize + + prepare-agent + + + + + + + + diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java new file mode 100644 index 0000000..138076e --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java @@ -0,0 +1,39 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.cds.services.cds.CqnService; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import io.github.resilience4j.retry.Retry; +import java.util.Map; + +public interface AICoreService extends CqnService { + + String DEFAULT_NAME = "AICore"; + String RESOURCE_GROUPS = "AICore.resourceGroups"; + String DEPLOYMENTS = "AICore.deployments"; + String CONFIGURATIONS = "AICore.configurations"; + + String resourceGroupForTenant(String tenantId); + + String deploymentId(String resourceGroupId, ModelDeploymentSpec spec); + + ApiClient inferenceClient(String resourceGroupId, String deploymentId); + + boolean isMultiTenancyEnabled(); + + Retry getRetry(); + + String getDefaultResourceGroup(); + + String getResourceGroupPrefix(); + + Map getTenantResourceGroupCache(); + + Map getResourceGroupDeploymentCache(); + + void clearTenantCache(String tenantId); + + String resolveResourceGroupFromKeys(Map keys); +} 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..358d3d9 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -0,0 +1,97 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +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.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; + +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(); + } + + @Override + public void services(CdsRuntimeConfigurer configurer) { + CdsRuntime runtime = configurer.getCdsRuntime(); + + boolean hasBinding = hasAICoreBinding(runtime); + + boolean multiTenancyEnabled = + runtime + .getEnvironment() + .getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false); + + if (hasBinding) { + AICoreServiceImpl service = + new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + configurer.service(service); + logger.info("Registered AICoreService backed by AI Core binding."); + } else { + MockAICoreServiceImpl mockService = + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(mockService); + logger.info("Registered MockAICoreService (no AI Core binding found)."); + } + } + + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + CdsRuntime runtime = configurer.getCdsRuntime(); + + boolean hasBinding = hasAICoreBinding(runtime); + + if (hasBinding) { + AICoreServiceImpl service = + (AICoreServiceImpl) + runtime + .getServiceCatalog() + .getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + 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 { + MockAICoreServiceImpl mockService = + (MockAICoreServiceImpl) + runtime + .getServiceCatalog() + .getService(AICoreService.class, AICoreService.DEFAULT_NAME); + configurer.eventHandler(new MockEntityHandler()); + configurer.eventHandler(new AICoreApplicationServiceHandler(mockService)); + 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..88e7b3a --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -0,0 +1,381 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.utils.services.AbstractCqnService; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AICoreServiceImpl extends AbstractCqnService implements AICoreService { + + 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; + private final Cache deploymentLocks; + + 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) { + super(name, runtime); + this.multiTenancyEnabled = multiTenancyEnabled; + CdsEnvironment env = runtime.getEnvironment(); + this.maxRetries = + env.getProperty("cds.requires.AICore.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); + this.initialDelayMs = + env.getProperty("cds.requires.AICore.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); + this.defaultResourceGroup = + env.getProperty("cds.requires.AICore.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); + this.resourceGroupPrefix = + env.getProperty( + "cds.requires.AICore.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); + this.retry = buildRetry(maxRetries, initialDelayMs); + this.tenantResourceGroupCache = newCache(); + this.resourceGroupDeploymentCache = newCache(); + this.deploymentLocks = newCache(); + this.deploymentApi = new DeploymentApi(); + this.configurationApi = new ConfigurationApi(); + this.resourceGroupApi = new ResourceGroupApi(); + this.sdkService = new AiCoreService(); + } + + 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) { + logger.debug("Multi-tenancy disabled, using resource group {}", defaultResourceGroup); + return defaultResourceGroup; + } + return getOrCreateResourceGroupForTenant(tenantId); + } + + @Override + public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { + String cacheKey = deploymentCacheKey(resourceGroupId, spec); + Object lock = deploymentLocks.get(cacheKey, k -> new Object()); + synchronized (lock) { + String cached = resourceGroupDeploymentCache.getIfPresent(cacheKey); + if (cached != null) { + var current = deploymentApi.get(resourceGroupId, cached); + if (AiDeploymentStatus.RUNNING.equals(current.getStatus()) + || AiDeploymentStatus.PENDING.equals(current.getStatus())) { + return cached; + } + 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); + } + + 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 CdsRuntime getRuntime() { + return runtime; + } + + 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"); + } + String tenantId = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); + return resourceGroupForTenant(tenantId); + } + + @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 + .asMap() + .keySet() + .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } + + private 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 == 412)) { + return true; + } + } + t = t.getCause(); + } + return false; + } + + private static Retry buildRetry(int maxAttempts, long initialDelayMs) { + RetryConfig config = + RetryConfig.custom() + .maxAttempts(maxAttempts) + .intervalFunction(IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0)) + .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..e605e15 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java @@ -0,0 +1,115 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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 " + resourceGroupId + " for tenant " + 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/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java new file mode 100644 index 0000000..c13a197 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java @@ -0,0 +1,113 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.utils.services.AbstractCqnService; +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 AbstractCqnService implements AICoreService { + + 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) { + 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.requires.AICore.resourceGroup", String.class, "default"); + this.resourceGroupPrefix = + env.getProperty("cds.requires.AICore.resourceGroupPrefix", String.class, "cds-"); + this.multiTenancyEnabled = + env.getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false); + } + + @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/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java new file mode 100644 index 0000000..5ca2e1f --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java @@ -0,0 +1,16 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +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; + +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/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..cc92b34 --- /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-feature-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..3793e76 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -0,0 +1,40 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.handler.EventHandler; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +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); + } + + 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 new ArrayList<>(); + return resources.stream().map(mapper).collect(Collectors.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..b811c96 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -0,0 +1,61 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.core.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.request.RequestContext; +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 = "resourceGroupForTenant") + public void onResourceGroupForTenant(EventContext context) { + Map params = asMap(context.get("data")); + String tenant = (String) params.get("tenant"); + if (tenant == null) { + tenant = RequestContext.getCurrent(service.getRuntime()).getUserInfo().getTenant(); + } + String result = service.resourceGroupForTenant(tenant); + context.put("result", result); + context.setCompleted(); + } + + @On(event = "stop", entity = AICoreService.DEPLOYMENTS) + public void onStop(EventContext context) { + Map keys = asMap(context.get("keys")); + String deploymentId = (String) keys.get("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..d3fb8d9 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -0,0 +1,151 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.CdsData; +import com.sap.cds.feature.aicore.core.AICoreService; +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.CqnInsert; +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.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)); + logger.debug( + "Reading configurations for resourceGroup={}, keys={}, values={}", + resourceGroupId, + keys, + values); + + String id = (String) keys.get("id"); + if (id != null) { + AiConfiguration config = configurationApi.get(resourceGroupId, id); + context.setResult(List.of(toMap(config, resourceGroupId))); + } else { + String scenarioId = (String) values.get("scenarioId"); + AiConfigurationList result = + configurationApi.query(resourceGroupId, scenarioId, null, null, null, null, null, null); + List> results = + mapResources(result.getResources(), c -> toMap(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) { + CqnInsert insert = context.getCqn(); + List> entries = insert.entries(); + List> results = new ArrayList<>(); + + for (Map entry : entries) { + String resourceGroupId = resolveResourceGroup(entry); + String name = (String) entry.get("name"); + String executableId = (String) entry.get("executableId"); + String scenarioId = (String) entry.get("scenarioId"); + + AiConfigurationBaseData request = + AiConfigurationBaseData.create() + .name(name) + .executableId(executableId) + .scenarioId(scenarioId); + + @SuppressWarnings("unchecked") + List> paramBindings = + (List>) entry.get("parameterBindings"); + if (paramBindings != null) { + List sdkBindings = + paramBindings.stream() + .map( + p -> + AiParameterArgumentBinding.create() + .key((String) p.get("key")) + .value((String) p.get("value"))) + .toList(); + request.parameterBindings(sdkBindings); + } + + var response = configurationApi.create(resourceGroupId, request); + CdsData result = CdsData.create(entry); + result.put("id", response.getId()); + results.add(result); + logger.debug( + "Created configuration {} in resource group {}", response.getId(), resourceGroupId); + } + context.setResult(results); + } + + private CdsData toMap(AiConfiguration config, String resourceGroupId) { + CdsData data = CdsData.create(); + data.put("id", config.getId()); + data.put("name", config.getName()); + data.put("executableId", config.getExecutableId()); + data.put("scenarioId", config.getScenarioId()); + data.put("createdAt", config.getCreatedAt()); + if (config.getParameterBindings() != null) { + List bindings = + config.getParameterBindings().stream() + .map( + b -> { + CdsData bm = CdsData.create(); + bm.put("key", b.getKey()); + bm.put("value", b.getValue()); + return bm; + }) + .toList(); + data.put("parameterBindings", bindings); + } + if (config.getInputArtifactBindings() != null) { + List bindings = + config.getInputArtifactBindings().stream() + .map( + b -> { + CdsData bm = CdsData.create(); + bm.put("key", b.getKey()); + bm.put("artifactId", b.getArtifactId()); + return bm; + }) + .toList(); + data.put("inputArtifactBindings", bindings); + } + data.putPath("resourceGroup.resourceGroupId", 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..f1e6c76 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -0,0 +1,236 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.CdsData; +import com.sap.cds.feature.aicore.core.AICoreService; +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.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.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 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)); + + String id = (String) keys.get("id"); + if (id != null) { + AiDeploymentResponseWithDetails deployment = deploymentApi.get(resourceGroupId, id); + context.setResult(List.of(toMap(deployment, resourceGroupId))); + } else { + AiDeploymentList result = + deploymentApi.query(resourceGroupId, null, null, null, null, null, null, null); + context.setResult(mapResources(result.getResources(), d -> toMap(d, resourceGroupId))); + } + } + + @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) + public void onCreate(CdsCreateEventContext context) { + CqnInsert insert = context.getCqn(); + List> entries = insert.entries(); + List> results = new ArrayList<>(); + + for (Map entry : entries) { + String resourceGroupId = resolveResourceGroup(entry); + String configurationId = (String) entry.get("configurationId"); + + AiDeploymentCreationRequest request = + AiDeploymentCreationRequest.create().configurationId(configurationId); + + if (entry.containsKey("ttl")) { + request.ttl((String) entry.get("ttl")); + } + + var response = deploymentApi.create(resourceGroupId, request); + CdsData result = CdsData.create(entry); + result.put("id", response.getId()); + result.put("status", response.getStatus().getValue()); + results.add(result); + 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) { + CqnUpdate update = context.getCqn(); + List> entries = update.entries(); + if (entries.isEmpty()) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); + } + Map data = entries.get(0); + if (!data.containsKey("targetStatus") && !data.containsKey("configurationId")) { + 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(update).targetKeys(); + + String deploymentId = (String) keys.get("id"); + String resourceGroupId = resolveResourceGroup(merge(keys, data)); + + AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); + + if (data.containsKey("targetStatus")) { + String targetStatus = (String) data.get("targetStatus"); + modRequest.targetStatus(AiDeploymentTargetStatus.fromValue(targetStatus)); + } + if (data.containsKey("configurationId")) { + modRequest.configurationId((String) data.get("configurationId")); + } + + deploymentApi.modify(resourceGroupId, deploymentId, modRequest); + logger.debug("Updated deployment {} in resource group {}", deploymentId, resourceGroupId); + context.setResult(List.of(CdsData.create(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("id"); + String resourceGroupId = resolveResourceGroup(keys); + + deploymentApi.delete(resourceGroupId, deploymentId); + logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId); + context.setResult(List.of()); + } + + // CPD-OFF - SDK types AiDeploymentResponseWithDetails and AiDeployment share no common interface + private CdsData toMap(AiDeploymentResponseWithDetails d, String resourceGroupId) { + return buildDeploymentData( + 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 CdsData toMap(AiDeployment d, String resourceGroupId) { + return buildDeploymentData( + 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 CdsData buildDeploymentData( + String id, + String deploymentUrl, + String configurationId, + String configurationName, + String executableId, + String scenarioId, + String status, + String statusMessage, + String targetStatus, + String lastOperation, + String latestRunningConfigurationId, + String ttl, + Object createdAt, + Object modifiedAt, + Object submissionTime, + Object startTime, + Object completionTime, + String resourceGroupId) { + CdsData data = CdsData.create(); + data.put("id", id); + data.put("deploymentUrl", deploymentUrl); + data.put("configurationId", configurationId); + data.put("configurationName", configurationName); + data.put("executableId", executableId); + data.put("scenarioId", scenarioId); + data.put("status", status); + data.put("statusMessage", statusMessage); + data.put("targetStatus", targetStatus); + data.put("lastOperation", lastOperation); + data.put("latestRunningConfigurationId", latestRunningConfigurationId); + data.put("ttl", ttl); + data.put("createdAt", createdAt); + data.put("modifiedAt", modifiedAt); + data.put("submissionTime", submissionTime); + data.put("startTime", startTime); + data.put("completionTime", completionTime); + data.putPath("resourceGroup.resourceGroupId", resourceGroupId); + return data; + } +} 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..229a3c7 --- /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-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.aicore.core.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..1037b68 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -0,0 +1,198 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.core.AICoreService; +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.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.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("resourceGroupId"); + if (resourceGroupId == null) { + resourceGroupId = (String) values.get("resourceGroupId"); + } + + if (resourceGroupId != null) { + BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); + context.setResult(List.of(toMap(rg))); + } else { + List labelSelector = null; + if (values.containsKey("tenantId")) { + String tenantId = (String) values.get("tenantId"); + labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); + } + 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) { + CqnInsert insert = context.getCqn(); + List> entries = insert.entries(); + List> results = new ArrayList<>(); + + for (Map entry : entries) { + String resourceGroupId = (String) entry.get("resourceGroupId"); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create().resourceGroupId(resourceGroupId); + + @SuppressWarnings("unchecked") + List> labels = (List>) entry.get("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.containsKey("tenantId") && !userSuppliedTenantLabel) { + String tenantId = (String) entry.get("tenantId"); + mergedLabels.add( + BckndResourceGroupLabel.create() + .key(AICoreServiceImpl.TENANT_LABEL_KEY) + .value(tenantId)); + } + + 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); + + Map data = update.entries().get(0); + BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); + + @SuppressWarnings("unchecked") + List> labels = (List>) data.get("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); + resourceGroupApi.delete(resourceGroupId); + logger.debug("Deleted resource group {}", resourceGroupId); + context.setResult(List.of()); + } + + private String resolveResourceGroupId(Map keys) { + if (keys.containsKey("resourceGroupId")) { + return (String) keys.get("resourceGroupId"); + } + if (keys.containsKey("tenantId")) { + return service.resourceGroupForTenant((String) keys.get("tenantId")); + } + return service.getDefaultResourceGroup(); + } + + 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 CdsData toMap(BckndResourceGroup rg) { + CdsData data = CdsData.create(); + data.put("resourceGroupId", rg.getResourceGroupId()); + data.put("status", rg.getStatus().getValue()); + data.put("statusMessage", rg.getStatusMessage()); + data.put("createdAt", rg.getCreatedAt()); + if (rg.getLabels() != null) { + List labels = new ArrayList<>(rg.getLabels().size()); + for (BckndResourceGroupLabel l : rg.getLabels()) { + CdsData lm = CdsData.create(); + lm.put("key", l.getKey()); + lm.put("value", l.getValue()); + labels.add(lm); + if (AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())) { + data.put("tenantId", l.getValue()); + } + } + data.put("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..f60e272 --- /dev/null +++ b/cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds @@ -0,0 +1,138 @@ +@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; + }; + + function resourceGroupForTenant(tenant: String) returns String; + + 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/srv/src/main/resources/spotbugs-exclusion-filter.xml b/cds-feature-ai-core/src/main/resources/spotbugs-exclusion-filter.xml similarity index 100% rename from srv/src/main/resources/spotbugs-exclusion-filter.xml rename to cds-feature-ai-core/src/main/resources/spotbugs-exclusion-filter.xml 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..7a1053c --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java @@ -0,0 +1,172 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.time.Duration; +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_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 deploymentLocksFieldIsBoundedCaffeineCache() throws NoSuchFieldException { + Field field = AICoreServiceImpl.class.getDeclaredField("deploymentLocks"); + assertThat(field.getType()).isEqualTo(Cache.class); + } + + @Test + void caffeineGetReturnsSameLockObjectForSameKey() { + Cache locks = + Caffeine.newBuilder().maximumSize(10_000).expireAfterAccess(Duration.ofHours(1)).build(); + + Object lock1 = locks.get("rg-1", k -> new Object()); + Object lock2 = locks.get("rg-1", k -> new Object()); + Object differentRg = locks.get("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"); + Cache deploymentLocks = readCache(service, "deploymentLocks"); + + 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.asMap()).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"); + Cache deploymentLocks = readCache(service, "deploymentLocks"); + + 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.asMap()) + .doesNotContainKey(resourceGroupA) + .containsKey(resourceGroupB); + } + + @Test + void clearTenantCacheIsNoOpForUnknownTenant() throws Exception { + String resourceGroupId = "cds-tenant-1"; + + AICoreServiceImpl service = freshService(); + Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); + Cache deploymentLocks = readCache(service, "deploymentLocks"); + + deploymentCache.put(resourceGroupId, "deployment-id"); + deploymentLocks.put(resourceGroupId, new Object()); + + service.clearTenantCache("unknown-tenant"); + + assertThat(deploymentCache.asMap()).containsKey(resourceGroupId); + assertThat(deploymentLocks.asMap()).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", Caffeine.newBuilder().build()); + 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); + } + + 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..95bbba2 --- /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-feature-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/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..0aef6a9 --- /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-feature-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..9b4da06 --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -0,0 +1,69 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.CdsUpdateEventContext; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DeploymentHandlerTest { + + @Mock private AICoreServiceImpl service; + @Mock private DeploymentApi deploymentApi; + @Mock private CdsUpdateEventContext context; + @Mock private CqnUpdate update; + + private DeploymentHandler cut; + + @BeforeEach + void setup() { + when(service.getDeploymentApi()).thenReturn(deploymentApi); + cut = new DeploymentHandler(service); + } + + @Test + void onUpdate_emptyEntries_throwsBadRequest() { + when(context.getCqn()).thenReturn(update); + when(update.entries()).thenReturn(List.of()); + + assertThatThrownBy(() -> cut.onUpdate(context)) + .isInstanceOfSatisfying( + ServiceException.class, + e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) + .hasMessageContaining("No update payload provided"); + + verifyNoInteractions(deploymentApi); + } + + @Test + void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { + when(context.getCqn()).thenReturn(update); + when(update.entries()).thenReturn(List.of(Map.of("ttl", "1d"))); + + assertThatThrownBy(() -> cut.onUpdate(context)) + .isInstanceOfSatisfying( + ServiceException.class, + e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) + .hasMessageContaining("targetStatus") + .hasMessageContaining("configurationId"); + + verifyNoInteractions(deploymentApi); + } +} 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..8c9743a --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -0,0 +1,127 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.ql.cqn.CqnInsert; +import com.sap.cds.services.cds.CdsCreateEventContext; +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 ResourceGroupHandlerTest { + + @Mock private AICoreServiceImpl service; + @Mock private ResourceGroupApi resourceGroupApi; + @Mock private CdsCreateEventContext context; + @Mock private CqnInsert insert; + + 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"); + when(context.getCqn()).thenReturn(insert); + when(insert.entries()).thenReturn(List.of(entry)); + + handler.onCreate(context); + + 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"))); + when(context.getCqn()).thenReturn(insert); + when(insert.entries()).thenReturn(List.of(entry)); + + handler.onCreate(context); + + 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"))); + when(context.getCqn()).thenReturn(insert); + when(insert.entries()).thenReturn(List.of(entry)); + + handler.onCreate(context); + + 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"))); + when(context.getCqn()).thenReturn(insert); + when(insert.entries()).thenReturn(List.of(entry)); + + handler.onCreate(context); + + BckndResourceGroupsPostRequest request = captureCreateRequest(); + assertThat(request.getLabels()) + .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) + .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-user")); + } + + private BckndResourceGroupsPostRequest captureCreateRequest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + return captor.getValue(); + } +} diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md new file mode 100644 index 0000000..e933b1c --- /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: + AICore: + 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/pom.xml b/cds-feature-recommendations/pom.xml new file mode 100644 index 0000000..9f1352e --- /dev/null +++ b/cds-feature-recommendations/pom.xml @@ -0,0 +1,130 @@ + + + 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.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.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..f6ae25c --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -0,0 +1,401 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.Result; +import com.sap.cds.feature.aicore.core.AICoreService; +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.CdsElement; +import com.sap.cds.reflect.CdsSimpleType; +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.math.BigDecimal; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(value = "*", type = ApplicationService.class) +class FioriRecommendationHandler implements EventHandler { + + private final AICoreService aiCoreService; + private final RecommendationClientResolver clientResolver; + private final Set entitiesWithoutPredictions = ConcurrentHashMap.newKeySet(); + private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); + private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; + private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = + "@Common.ValueListWithFixedValues"; + private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; + 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); + + FioriRecommendationHandler( + AICoreService aiCoreService, RecommendationClientResolver clientResolver) { + this.aiCoreService = aiCoreService; + this.clientResolver = clientResolver; + } + + @After(entity = "*") + public void afterRead(CdsReadEventContext context, List dataList) { + CdsStructuredType target = context.getTarget(); + if (target == null) { + return; + } + String entityName = target.getQualifiedName(); + if (entitiesWithoutPredictions.contains(entityName)) { + 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; + } + List predictionElementNames = + rowType + .elements() + .filter( + byAnnotation(VALUE_LIST_ANNOTATION) + .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) + .filter(e -> !e.getType().isAssociation()) + .map(CdsElement::getName) + .toList(); + if (predictionElementNames.isEmpty()) { + entitiesWithoutPredictions.add(entityName); + return; + } + + List contextColumns = + 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(); + if (contextColumns.isEmpty()) { + logger.debug("No suitable context columns found, skipping predictions."); + return; + } + + List keyNames = target.keyElements().map(CdsElement::getName).toList(); + boolean syntheticKeyNeeded = + keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0))); + String indexColumn = + syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID"); + + List selectColumns = new ArrayList<>(contextColumns); + for (String key : keyNames) { + if (!selectColumns.contains(key)) { + selectColumns.add(key); + } + } + int limit = + context + .getCdsRuntime() + .getEnvironment() + .getProperty( + "cds.requires.recommendations.contextRowLimit", + Integer.class, + DEFAULT_CONTEXT_ROW_LIMIT); + var select = + Select.from(target.getQualifiedName()) + .columns(selectColumns.toArray(String[]::new)) + .where( + predictionElementNames.stream() + .map(col -> CQL.get(col).isNotNull()) + .collect(CQL.withAnd())) + .limit(limit); + 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())); + + PersistenceService db = + context + .getServiceCatalog() + .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + List contextRows = new ArrayList<>(db.run(select).list()); + if (contextRows.size() < 2) { + logger.debug("Not enough context rows (minimum 2), skipping predictions."); + return; + } + + CdsData predictRow = buildPredictRow(row, predictionElementNames); + if (predictRow == null) { + return; + } + + List allRows = new ArrayList<>(); + if (syntheticKeyNeeded) { + for (CdsData contextRow : contextRows) { + contextRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(contextRow, keyNames)); + allRows.add(contextRow); + } + predictRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(row, keyNames)); + } else { + allRows.addAll(contextRows); + } + allRows.add(predictRow); + + String tenantId = context.getUserInfo().getTenant(); + RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); + List predictions = client.predict(allRows, predictionElementNames, 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; + } + + Map recommendations = + buildRecommendations(db, predictions.get(0), predictionElementNames, context, rowType); + row.put("SAP_Recommendations", recommendations); + } + + private CdsData buildPredictRow(CdsData row, List predictionElementNames) { + if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { + logger.debug("Current row already has values for all prediction columns, skipping."); + 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, List keyNames) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keyNames.size(); i++) { + if (i > 0) { + sb.append('\0'); + } + sb.append(keyNames.get(i)); + sb.append('\0'); + Object value = row.get(keyNames.get(i)); + if (value != null) { + sb.append(value); + } + } + return sb.toString(); + } + + private 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, "")); + values.put("RecommendedFieldScoreValue", 0.5); + values.put("RecommendedFieldIsSuggestion", true); + recommendations.put(col, List.of(values)); + } + return recommendations; + } + + 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 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 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/srv/src/main/java/com/sap/cds/feature/ai/client/MockAIClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java similarity index 51% rename from srv/src/main/java/com/sap/cds/feature/ai/client/MockAIClient.java rename to cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index 8367a80..14e2135 100644 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/MockAIClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -1,27 +1,24 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. */ -package com.sap.cds.feature.ai.client; +package com.sap.cds.feature.recommendation; +import com.sap.cds.CdsData; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -public class MockAIClient implements AIClient { +class MockRecommendationClient implements RecommendationClient { - private static final Logger logger = LoggerFactory.getLogger(MockAIClient.class); + private final Random random = new Random(); @Override - public List> fetchPredictions( - List rows, List predictionColumns, String tenantId) { - List> predictions = new ArrayList<>(); - Random random = new Random(); - - for (Map row : rows) { + 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) { @@ -31,26 +28,21 @@ public List> fetchPredictions( rows.stream() .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) .map(r -> r.get(col)) - .collect(java.util.stream.Collectors.toList()); + .toList(); Object contextValue = availableValues.isEmpty() ? null - : availableValues.get( - random.nextInt( - availableValues - .size())); // get a random value from the existing values for this - // column + : availableValues.get(random.nextInt(availableValues.size())); Map predictionEntry = new HashMap<>(); predictionEntry.put("prediction", contextValue); prediction.put(col, List.of(predictionEntry)); } } if (addPrediction) { - prediction.put("ID", row.get("ID")); - predictions.add(prediction); + prediction.put(indexColumn, row.get(indexColumn)); + predictions.add(CdsData.create(prediction)); } } - logger.info("Generated mock predictions: " + predictions.toString()); return predictions; } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java new file mode 100644 index 0000000..fbb2858 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java @@ -0,0 +1,12 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.CdsData; +import java.util.List; + +interface RecommendationClient { + + List predict(List rows, List predictionColumns, String indexColumn); +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java new file mode 100644 index 0000000..5592678 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java @@ -0,0 +1,12 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.feature.aicore.core.AICoreService; + +@FunctionalInterface +interface RecommendationClientResolver { + + RecommendationClient resolve(AICoreService aiCoreService, String tenantId); +} 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..b2762eb --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -0,0 +1,46 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.core.MockAICoreServiceImpl; +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, tenantId) -> new MockRecommendationClient() + : RecommendationConfiguration::resolveRptClient; + + configurer.eventHandler(new FioriRecommendationHandler(aiCoreService, resolver)); + } + + private static RecommendationClient resolveRptClient(AICoreService service, String tenantId) { + String resourceGroup = service.resourceGroupForTenant(tenantId); + String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + return new RptInferenceClient( + service.inferenceClient(resourceGroup, deploymentId), service.getRetry()); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java new file mode 100644 index 0000000..4165699 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java @@ -0,0 +1,101 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +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; + +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/RptModelSpec.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java new file mode 100644 index 0000000..4738fd9 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java @@ -0,0 +1,43 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; +import com.sap.ai.sdk.foundationmodels.rpt.RptModel; +import com.sap.cds.feature.aicore.core.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/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..cdb4158 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -0,0 +1,348 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.core.AICoreService; +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, tenantId) -> 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); + }); + } + + // ── 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 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 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..b3df6d1 --- /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-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.aicore.core.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..962c8fb --- /dev/null +++ b/cds-starter-ai/pom.xml @@ -0,0 +1,30 @@ + + + 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 + + + + 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..9e07824 --- /dev/null +++ b/coverage-report/pom.xml @@ -0,0 +1,210 @@ + + + 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 + + + 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/integration-tests/.cdsrc.json b/integration-tests/.cdsrc.json new file mode 100644 index 0000000..dff0396 --- /dev/null +++ b/integration-tests/.cdsrc.json @@ -0,0 +1,13 @@ +{ + "build": { + "target": ".", + "tasks": [ + { "for": "java", "src": "spring" } + ] + }, + "requires": { + "AICore": { + "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..03a563e --- /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-feature-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..6e66e65 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java @@ -0,0 +1,89 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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 static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +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 { + AICoreServiceImpl 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 { + AICoreServiceImpl 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); + } + } + + /** + * The {@code AICoreSetupHandler} is only registered when a real AI Core binding is present (see + * {@code AICoreServiceConfiguration#eventHandlers}); without one, the runtime falls back to the + * mock service and these lifecycle assertions don't apply. + */ + private AICoreServiceImpl getService() { + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + assumeTrue( + service instanceof AICoreServiceImpl, + "Skipping: no AI Core binding available, MockAICoreServiceImpl in use."); + return (AICoreServiceImpl) service; + } +} 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..01ab896 --- /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-feature-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.core.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +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 { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + AICoreServiceImpl service = + (AICoreServiceImpl) + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + assertThat(service.isMultiTenancyEnabled()).isTrue(); + assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); + } + + @Test + void unsubscribeTenant_clearsCaches() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + AICoreServiceImpl service = + (AICoreServiceImpl) + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + 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) { + } + } +} 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..79121a7 --- /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-feature-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.core.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +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() { + AICoreServiceImpl service = getService(); + assertThat(service.isMultiTenancyEnabled()).isTrue(); + } + + @Test + void differentTenants_getDifferentResourceGroups() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-1"); + subscriptionEndpointClient.subscribeTenant("tenant-2"); + + AICoreServiceImpl service = getService(); + + 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 { + subscriptionEndpointClient.subscribeTenant("tenant-1"); + + AICoreServiceImpl service = getService(); + String rg = service.getTenantResourceGroupCache().get("tenant-1"); + + assertThat(rg).startsWith(service.getResourceGroupPrefix()); + } + + @Test + void clearTenantCache_onlyAffectsTarget() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-1"); + subscriptionEndpointClient.subscribeTenant("tenant-2"); + + AICoreServiceImpl service = getService(); + 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 AICoreServiceImpl getService() { + return (AICoreServiceImpl) + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + } + + @AfterEach + void tearDown() { + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-1"); + } catch (Exception ignored) { + } + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-2"); + } catch (Exception ignored) { + } + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + } catch (Exception 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..51edfe5 --- /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-feature-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.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..65f4260 --- /dev/null +++ b/integration-tests/spring/pom.xml @@ -0,0 +1,128 @@ + + + 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.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 + + + + + + + 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..8024abe --- /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-feature-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..f97684c --- /dev/null +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -0,0 +1,24 @@ +spring: + datasource: + url: "jdbc:h2:mem:testdb" + driver-class-name: org.h2.Driver + sql: + init: + mode: always + +cds: + 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..daebbf5 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java @@ -0,0 +1,83 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.itest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.recommendation.RptModelSpec; +import org.junit.jupiter.api.Test; + +class AICoreServiceTest extends BaseIntegrationTest { + + @Test + void service_isRegistered() { + assertThat(getAICoreService()).isNotNull(); + assertThat(getAICoreService()).isInstanceOf(AICoreService.class); + } + + @Test + void resourceGroupForTenant_singleTenancy_returnsDefault() { + AICoreService service = getAICoreService(); + if (!service.isMultiTenancyEnabled()) { + String result = service.resourceGroupForTenant("any-tenant"); + assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + } + } + + @Test + void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { + AICoreService service = getAICoreService(); + 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() { + AICoreService service = getAICoreService(); + 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() { + AICoreService service = getAICoreService(); + 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() { + AICoreService service = getAICoreService(); + 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..e7e230d --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java @@ -0,0 +1,122 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.core.AICoreService; +import com.sap.cds.feature.recommendation.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.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ResourceGroupCleanupExtension.class) +class ActionTest extends BaseIntegrationTest { + + private static final String TEST_RG = "default"; + + @Test + void resourceGroupForTenant_singleTenancy_returnsDefault() { + AICoreService service = getAICoreService(); + assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); + String result = service.resourceGroupForTenant("any-tenant-id"); + assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + } + + @Test + void resourceGroupForTenant_multiTenancy_createsGroup() { + AICoreService service = getAICoreService(); + 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() { + AICoreService service = getAICoreService(); + String resourceGroup = service.getDefaultResourceGroup(); + + String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + assertThat(deploymentId).isNotNull().isNotBlank(); + } + + @Test + void deploymentId_cachedOnSecondCall() { + AICoreService service = getAICoreService(); + String resourceGroup = service.getDefaultResourceGroup(); + + String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + String second = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + assertThat(second).isEqualTo(first); + } + + @Test + void stop_deployment_changesTargetStatus() { + CqnService service = getAICoreCqnService(); + + Result deployments = + service.run( + Select.from("AICore.deployments") + .where(d -> d.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + + 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", TEST_RG))); + + Result readResult = + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("id") + .eq(targetId) + .and(d.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + + assertThat(readResult.list()).hasSize(1); + Row row = readResult.single(); + assertThat(row.get("targetStatus")).isIn("STOPPED", "STOPPING"); + } + + @Test + void resolveResourceGroupFromKeys_directKey() { + AICoreService service = getAICoreService(); + Map keys = Map.of("resourceGroup_resourceGroupId", "my-rg"); + String resolved = service.resolveResourceGroupFromKeys(keys); + assertThat(resolved).isEqualTo("my-rg"); + } + + @Test + void resolveResourceGroupFromKeys_nestedMap() { + AICoreService service = getAICoreService(); + 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..305edd9 --- /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-feature-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..210f17a --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -0,0 +1,68 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.AICoreService; +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 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 { + + @Autowired protected MockMvc mockMvc; + + @Autowired protected CdsRuntime runtime; + + protected AICoreService getAICoreService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + } + + protected CqnService getAICoreCqnService() { + return (CqnService) getAICoreService(); + } + + protected String getOrCreateRptConfig(CqnService service, String resourceGroup) { + Result configs = + service.run( + Select.from("AICore.configurations") + .where( + c -> + c.get("scenarioId") + .eq("foundation-models") + .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + + for (Row row : configs) { + if ("sap-rpt-1-small".equals(row.get("name"))) { + return (String) row.get("id"); + } + } + + Result created = + service.run( + Insert.into("AICore.configurations") + .entry( + Map.of( + "name", "sap-rpt-1-small", + "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"))))); + + return (String) created.single().get("id"); + } +} 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..8ec1c23 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java @@ -0,0 +1,134 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.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 { + + private static final String TEST_RG = "default"; + + @Test + void readAll_returnsConfigurations() { + CqnService service = getAICoreCqnService(); + Result result = + service.run( + Select.from("AICore.configurations") + .where(c -> c.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void readAll_filterByScenario() { + CqnService service = getAICoreCqnService(); + Result result = + service.run( + Select.from("AICore.configurations") + .where( + c -> + c.get("scenarioId") + .eq("foundation-models") + .and(c.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void create_andReadById() { + CqnService service = getAICoreCqnService(); + + 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", + TEST_RG, + "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(TEST_RG)))); + + 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 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", + TEST_RG, + "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(TEST_RG)))); + + 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..2ce3d6b --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java @@ -0,0 +1,98 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.CqnService; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DeploymentTest extends BaseIntegrationTest { + + private static final String TEST_RG = "default"; + + @Test + void readAll_returnsDeployments() { + CqnService service = getAICoreCqnService(); + Result result = + service.run( + Select.from("AICore.deployments") + .where(d -> d.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + + assertThat(result.list()).isNotNull(); + } + + @Test + void readSingle_returnsDeploymentDetails() { + CqnService service = getAICoreCqnService(); + Result all = + service.run( + Select.from("AICore.deployments") + .where(d -> d.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + + 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(TEST_RG)))); + + 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(); + } + + @Test + void update_targetStatus_stopsRunningDeployment() { + CqnService service = getAICoreCqnService(); + + Result deployments = + service.run( + Select.from("AICore.deployments") + .where(d -> d.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + + 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", TEST_RG))); + + Result readResult = + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("id") + .eq(targetId) + .and(d.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + + 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..4d47c8c --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java @@ -0,0 +1,97 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.core.AICoreService; +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() { + AICoreService service = getAICoreService(); + if (tenantA != null) { + service.clearTenantCache(tenantA); + tenantA = null; + } + if (tenantB != null) { + service.clearTenantCache(tenantB); + tenantB = null; + } + } + + @Test + void differentTenants_getDifferentResourceGroups() { + AICoreService service = getAICoreService(); + 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() { + AICoreService service = getAICoreService(); + 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() { + AICoreService service = getAICoreService(); + 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() { + AICoreService service = getAICoreService(); + 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() { + AICoreService service = getAICoreService(); + 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..305bb47 --- /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-feature-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..88f3328 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupCleanupExtension.java @@ -0,0 +1,64 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ResourceGroupCleanupExtension implements BeforeAllCallback, AfterAllCallback { + + private static final Logger logger = LoggerFactory.getLogger(ResourceGroupCleanupExtension.class); + + @Override + public void beforeAll(ExtensionContext context) { + cleanupOnce(context, "resourceGroupCleanupBeforeDone"); + } + + @Override + public void afterAll(ExtensionContext context) { + cleanupOnce(context, "resourceGroupCleanupAfterDone"); + } + + private void cleanupOnce(ExtensionContext context, String storeKey) { + ExtensionContext.Store store = context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL); + if (store.get(storeKey) != null) { + return; + } + store.put(storeKey, true); + deleteTestResourceGroups(); + } + + private void deleteTestResourceGroups() { + String envKey = System.getenv("AICORE_SERVICE_KEY"); + if (envKey == null || envKey.isBlank()) { + logger.debug("No AI Core binding available, skipping resource group cleanup."); + return; + } + + try { + ResourceGroupApi api = new ResourceGroupApi(); + BckndResourceGroupList list = api.getAll(null, null, null, null, null, null, null); + + for (BckndResourceGroup rg : list.getResources()) { + String id = rg.getResourceGroupId(); + if (id != null && !"default".equals(id)) { + try { + api.delete(id); + logger.info("Cleaned up integration test resource group: {}", id); + } catch (Exception e) { + logger.warn("Failed to delete resource group {}: {}", id, e.getMessage()); + } + } + } + } catch (Exception e) { + logger.warn("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..26b153c --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java @@ -0,0 +1,146 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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.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 { + getAICoreCqnService() + .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); + + service.run(Delete.from("AICore.resourceGroups").where(r -> r.get("resourceGroupId").eq(rgId))); + + 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..5e95f48 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/NonStandardKeyRecommendationTest.java @@ -0,0 +1,165 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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() { + 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..b9cd108 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/recommendation/itest/RecommendationTest.java @@ -0,0 +1,362 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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() { + 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/package.json b/package.json deleted file mode 100644 index 0dac673..0000000 --- a/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "cds-feature-ai", - "version": "1.0.0-SNAPSHOT", - "description": "CAP Java AI Features Plugin", - "license": "Apache-2.0", - "type": "module", - "scripts": { - "lint": "cds lint", - "prettier:check": "cds format --check" - }, - "devDependencies": { - "@sap/cds-dk": "^9.3.2", - "@sap/eslint-plugin-cds": "^4", - "eslint": "^10" - } -} diff --git a/pom.xml b/pom.xml index 0be7f13..da3fa6e 100644 --- a/pom.xml +++ b/pom.xml @@ -3,12 +3,12 @@ 4.0.0 com.sap.cds - cds-feature-ai-root + cds-starter-ai-root ${revision} pom - CDS Feature for AI - Root - This artifact is a CAP Java plugin that provides smart recommendations using the SAP RPT-1 model. + CDS Starter AI - Root + Aggregator for CAP Java AI plugins (recommendations, AI Core client) SAP SE @@ -30,8 +30,18 @@ + + scm:git:git@github.com:cap-java/cds-feature-ai.git + scm:git:git@github.com:cap-java/cds-feature-ai.git + https://github.com/cap-java/cds-feature-ai + + - srv + cds-feature-ai-core + cds-feature-recommendations + cds-starter-ai + integration-tests + coverage-report @@ -53,25 +63,45 @@ 17 + 4.9.0 - 3.5.14 + + 1.18.0 + + 3.4.5 + 0.8.14 + 6.0.3 + 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.2 + 1.7.3 - 5.28.0 + + 5.30.0 + + 3.2.0 + 4.9.8.3 - 1.23.0 true @@ -79,15 +109,6 @@ - - - org.springframework.boot - spring-boot-dependencies - ${spring.boot.version} - pom - import - - com.sap.cds cds-services-bom @@ -120,17 +141,33 @@ import + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + + com.sap.cds + cds-feature-ai-core + ${revision} + + com.sap.cds - cds-feature-ai + cds-feature-recommendations ${revision} - - org.bouncycastle - bcprov-jdk18on - 1.84 + com.sap.cds + cds-feature-ai-integration-tests-spring + ${revision} + + + com.sap.cds + cds-feature-ai-integration-tests-mtx-local + ${revision} @@ -147,6 +184,7 @@ test + org.assertj assertj-core @@ -225,11 +263,6 @@ flatten-maven-plugin ${maven.flatten.plugin.version} - - org.pitest - pitest-maven - ${pitest.maven.plugin.version} - com.github.spotbugs spotbugs-maven-plugin @@ -307,7 +340,7 @@ **/*Test** - + com.sap.cloud.sdk.quality @@ -351,6 +384,7 @@ + com.diffplug.spotless spotless-maven-plugin @@ -384,5 +418,50 @@ - + + + skip-integration-tests + + cds-feature-ai-core + cds-feature-recommendations + cds-starter-ai + + + + + 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 index 1cb5db6..94e9eda 100644 --- a/samples/bookshop/.cdsrc.json +++ b/samples/bookshop/.cdsrc.json @@ -1,8 +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/app/xs-app.json b/samples/bookshop/app/xs-app.json deleted file mode 100644 index 5509e99..0000000 --- a/samples/bookshop/app/xs-app.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "welcomeFile": "/index.html", - "authenticationMethod": "route", - "routes": [ - { - "source": "^/odata/(.*)$", - "target": "/odata/$1", - "destination": "srv-api", - "authenticationType": "xsuaa", - "csrfProtection": false - }, - { - "source": "^/(.*)$", - "target": "$1", - "localDir": ".", - "authenticationType": "xsuaa" - } - ] -} diff --git a/samples/bookshop/db/src/.hdiconfig b/samples/bookshop/db/src/.hdiconfig deleted file mode 100644 index 03673f2..0000000 --- a/samples/bookshop/db/src/.hdiconfig +++ /dev/null @@ -1,139 +0,0 @@ -{ - "file_suffixes": { - "csv": { - "plugin_name": "com.sap.hana.di.tabledata.source" - }, - "hdbafllangprocedure": { - "plugin_name": "com.sap.hana.di.afllangprocedure" - }, - "hdbanalyticprivilege": { - "plugin_name": "com.sap.hana.di.analyticprivilege" - }, - "hdbcalculationview": { - "plugin_name": "com.sap.hana.di.calculationview" - }, - "hdbcollection": { - "plugin_name": "com.sap.hana.di.collection" - }, - "hdbconstraint": { - "plugin_name": "com.sap.hana.di.constraint" - }, - "hdbdropcreatetable": { - "plugin_name": "com.sap.hana.di.dropcreatetable" - }, - "hdbflowgraph": { - "plugin_name": "com.sap.hana.di.flowgraph" - }, - "hdbfunction": { - "plugin_name": "com.sap.hana.di.function" - }, - "hdbgraphworkspace": { - "plugin_name": "com.sap.hana.di.graphworkspace" - }, - "hdbhadoopmrjob": { - "plugin_name": "com.sap.hana.di.virtualfunctionpackage.hadoop" - }, - "hdbindex": { - "plugin_name": "com.sap.hana.di.index" - }, - "hdblibrary": { - "plugin_name": "com.sap.hana.di.library" - }, - "hdbmigrationtable": { - "plugin_name": "com.sap.hana.di.table.migration" - }, - "hdbprocedure": { - "plugin_name": "com.sap.hana.di.procedure" - }, - "hdbprojectionview": { - "plugin_name": "com.sap.hana.di.projectionview" - }, - "hdbprojectionviewconfig": { - "plugin_name": "com.sap.hana.di.projectionview.config" - }, - "hdbreptask": { - "plugin_name": "com.sap.hana.di.reptask" - }, - "hdbresultcache": { - "plugin_name": "com.sap.hana.di.resultcache" - }, - "hdbrole": { - "plugin_name": "com.sap.hana.di.role" - }, - "hdbroleconfig": { - "plugin_name": "com.sap.hana.di.role.config" - }, - "hdbsearchruleset": { - "plugin_name": "com.sap.hana.di.searchruleset" - }, - "hdbsequence": { - "plugin_name": "com.sap.hana.di.sequence" - }, - "hdbstatistics": { - "plugin_name": "com.sap.hana.di.statistics" - }, - "hdbstructuredprivilege": { - "plugin_name": "com.sap.hana.di.structuredprivilege" - }, - "hdbsynonym": { - "plugin_name": "com.sap.hana.di.synonym" - }, - "hdbsynonymconfig": { - "plugin_name": "com.sap.hana.di.synonym.config" - }, - "hdbsystemversioning": { - "plugin_name": "com.sap.hana.di.systemversioning" - }, - "hdbtable": { - "plugin_name": "com.sap.hana.di.table" - }, - "hdbtabledata": { - "plugin_name": "com.sap.hana.di.tabledata" - }, - "hdbtabletype": { - "plugin_name": "com.sap.hana.di.tabletype" - }, - "hdbtrigger": { - "plugin_name": "com.sap.hana.di.trigger" - }, - "hdbview": { - "plugin_name": "com.sap.hana.di.view" - }, - "hdbvirtualfunction": { - "plugin_name": "com.sap.hana.di.virtualfunction" - }, - "hdbvirtualfunctionconfig": { - "plugin_name": "com.sap.hana.di.virtualfunction.config" - }, - "hdbvirtualpackagehadoop": { - "plugin_name": "com.sap.hana.di.virtualpackage.hadoop" - }, - "hdbvirtualpackagesparksql": { - "plugin_name": "com.sap.hana.di.virtualpackage.sparksql" - }, - "hdbvirtualprocedure": { - "plugin_name": "com.sap.hana.di.virtualprocedure" - }, - "hdbvirtualprocedureconfig": { - "plugin_name": "com.sap.hana.di.virtualprocedure.config" - }, - "hdbvirtualtable": { - "plugin_name": "com.sap.hana.di.virtualtable" - }, - "hdbvirtualtableconfig": { - "plugin_name": "com.sap.hana.di.virtualtable.config" - }, - "properties": { - "plugin_name": "com.sap.hana.di.tabledata.properties" - }, - "tags": { - "plugin_name": "com.sap.hana.di.tabledata.properties" - }, - "txt": { - "plugin_name": "com.sap.hana.di.copyonly" - }, - "hdbeshconfig": { - "plugin_name": "com.sap.hana.di.eshconfig" - } - } -} diff --git a/samples/bookshop/db/undeploy.json b/samples/bookshop/db/undeploy.json deleted file mode 100644 index aa14b9f..0000000 --- a/samples/bookshop/db/undeploy.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - "src/gen/**/*.hdbview", - "src/gen/**/*.hdbindex", - "src/gen/**/*.hdbconstraint", - "src/gen/**/*_drafts.hdbtable", - "src/gen/**/*.hdbcalculationview" -] diff --git a/samples/bookshop/package.json b/samples/bookshop/package.json index be60784..60dd5df 100644 --- a/samples/bookshop/package.json +++ b/samples/bookshop/package.json @@ -8,7 +8,8 @@ "@sap/cds-dk": "^9.3.2" }, "dependencies": { - "@cap-js/ai": "*", + "@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 index 0e5e0ea..cf076ff 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -1,150 +1,175 @@ - - 4.0.0 - - customer - bookshop-parent - ${revision} - pom - - bookshop parent - - - - 1.0.0-SNAPSHOT - - - 17 - 4.4.0 - 3.5.6 - - - - - srv - - - - - - - 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-feature-ai - 1.0.0-SNAPSHOT - - - - - - - - - - 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 - process-resources - - flatten - - - - flatten.clean - clean - - clean - - - - - - - - maven-enforcer-plugin - 3.6.2 - - - Project Structure Checks - - enforce - - - - - 3.6.3 - - - ${java.version} - - - - true - - - - - - + + 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 index a9f5e36..91d3de5 100644 --- a/samples/bookshop/srv/admin-service.cds +++ b/samples/bookshop/srv/admin-service.cds @@ -3,3 +3,7 @@ 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/pom.xml b/samples/bookshop/srv/pom.xml index 6415be7..599800e 100644 --- a/samples/bookshop/srv/pom.xml +++ b/samples/bookshop/srv/pom.xml @@ -1,167 +1,173 @@ - - 4.0.0 - - - bookshop-parent - customer - ${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-feature-ai - - - - 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 + + + 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 - - - - - - - - \ No newline at end of file + + + + + + 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 index f395d21..6e93c1c 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/Application.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/Application.java @@ -6,8 +6,7 @@ @SpringBootApplication public class Application { - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } - + 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..d9196f9 --- /dev/null +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -0,0 +1,171 @@ +package customer.bookshop.handlers; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.recommendation.RptInferenceClient; +import com.sap.cds.feature.recommendation.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.request.RequestContext; +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 tenantId = (String) context.get("tenantId"); + String rgId = getAICoreService().resourceGroupForTenant(tenantId); + context.put("result", rgId); + context.setCompleted(); + } + + @On(event = "getMyResourceGroup") + public void onGetMyResourceGroup(EventContext context) { + String tenant = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); + String rgId = getAICoreService().resourceGroupForTenant(tenant); + 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 tenant = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); + String rg = service.resourceGroupForTenant(tenant); + String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); + RptInferenceClient client = + new RptInferenceClient(service.inferenceClient(rg, deploymentId), 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 index 72f17a6..9bb8f7b 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java @@ -1,10 +1,12 @@ package customer.bookshop.handlers; -import java.util.stream.Stream; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - +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; @@ -13,51 +15,50 @@ import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; - -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 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()))); + @Autowired private PersistenceService db; - // read new stock from database - Books book = db.run(Select.from(Books_.class).where(b -> b.ID().eq(context.getBook()))).single(Books.class); + @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()))); - // return new stock to client - ReturnType result = SubmitOrderContext.ReturnType.create(); - result.setStock(book.getStock()); + // read new stock from database + Books book = + db.run(Select.from(Books_.class).where(b -> b.ID().eq(context.getBook()))) + .single(Books.class); - OrderedBook orderedBook = OrderedBook.create(); - orderedBook.setBook(book.getId()); - orderedBook.setQuantity(context.getQuantity()); - orderedBook.setBuyer(context.getUserInfo().getName()); + // return new stock to client + ReturnType result = SubmitOrderContext.ReturnType.create(); + result.setStock(book.getStock()); - OrderedBookContext orderedBookEvent = OrderedBookContext.create(); - orderedBookEvent.setData(orderedBook); - context.getService().emit(orderedBookEvent); + OrderedBook orderedBook = OrderedBook.create(); + orderedBook.setBook(book.getId()); + orderedBook.setQuantity(context.getQuantity()); + orderedBook.setBuyer(context.getUserInfo().getName()); - context.setResult(result); - } + OrderedBookContext orderedBookEvent = OrderedBookContext.create(); + orderedBookEvent.setData(orderedBook); + context.getService().emit(orderedBookEvent); - @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)")); - } + 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 index 3d5bacd..bbd2210 100644 --- a/samples/bookshop/srv/src/main/resources/application.yaml +++ b/samples/bookshop/srv/src/main/resources/application.yaml @@ -1,8 +1,7 @@ - logging: level: root: INFO - com.sap.cds: DEBUG + com.sap.cds.feature.aicore.core: DEBUG --- spring: datasource: @@ -16,14 +15,14 @@ spring: cds: requires: AICore: - resourceGroup: custom-resource-group + resourceGroup: default security: mock: users: admin: password: admin roles: - - admin + - admin user: password: user data-source: diff --git a/samples/bookshop/srv/src/test/java/customer/bookshop/ApplicationTest.java b/samples/bookshop/srv/src/test/java/customer/bookshop/ApplicationTest.java index 152543a..9672588 100644 --- a/samples/bookshop/srv/src/test/java/customer/bookshop/ApplicationTest.java +++ b/samples/bookshop/srv/src/test/java/customer/bookshop/ApplicationTest.java @@ -18,4 +18,4 @@ class ApplicationTest { void checkApplicationContextCanBeLoaded() { assertThat(context).isNotNull(); } -} \ No newline at end of file +} 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 index 6c510cd..2bd34ed 100644 --- a/samples/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java +++ b/samples/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java @@ -2,41 +2,38 @@ 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; -import cds.gen.catalogservice.Books; - 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()); - } - + 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/srv/pom.xml b/srv/pom.xml deleted file mode 100644 index 0fddd54..0000000 --- a/srv/pom.xml +++ /dev/null @@ -1,218 +0,0 @@ - - - 4.0.0 - - - com.sap.cds - cds-feature-ai-root - ${revision} - - - cds-feature-ai - jar - - CDS Feature for AI - - - - com.sap.cds - cds-starter-spring-boot - - - - com.sap.cds - cds-services-utils - - - com.sap.cds - cds-adapter-api - - - com.sap.cds - cds4j-core - - - com.sap.cloud.mt - tools - - - - - - com.sap.ai.sdk - core - ${ai-sdk.version} - - - - com.sap.ai.sdk.foundationmodels - sap-rpt - ${ai-sdk.version} - - - - - - - - - ${project.artifactId} - - - org.pitest - pitest-maven - - - com.sap.cds.feature.ai.* - - - CONSTRUCTOR_CALLS - VOID_METHOD_CALLS - NON_VOID_METHOD_CALLS - CONDITIONALS_BOUNDARY - EMPTY_RETURNS - NEGATE_CONDITIONALS - REMOVE_CONDITIONALS_EQUAL_IF - REMOVE_CONDITIONALS_EQUAL_ELSE - REMOVE_CONDITIONALS_ORDER_IF - REMOVE_CONDITIONALS_ORDER_ELSE - - 95 - 90 - - - - - org.pitest - pitest-junit5-plugin - ${pitest.maven.plugin.version} - - - - - - - maven-clean-plugin - - - - ./ - - .flattened-pom.xml - - - - - - - auto-clean - - clean - - clean - - - - - - - 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 - - - - - - - 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/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java b/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java deleted file mode 100644 index 2482669..0000000 --- a/srv/src/main/java/com/sap/cds/feature/ai/AIRuntimeConfiguration.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai; - -import com.sap.cds.feature.ai.client.setup.AICoreSetupHandler; -import com.sap.cds.services.ServiceCatalog; -import com.sap.cds.services.persistence.PersistenceService; -import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cds.services.runtime.CdsRuntimeConfiguration; -import com.sap.cds.services.runtime.CdsRuntimeConfigurer; -import com.sap.cloud.environment.servicebinding.api.ServiceBinding; -import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class AIRuntimeConfiguration implements CdsRuntimeConfiguration { - - private static final Logger logger = LoggerFactory.getLogger(AIRuntimeConfiguration.class); - - @Override - public void eventHandlers(CdsRuntimeConfigurer configurer) { - - CdsRuntime runtime = configurer.getCdsRuntime(); - ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); - - PersistenceService persistenceService = - serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - - Optional binding = - runtime - .getEnvironment() - .getServiceBindings() - .filter(b -> b.getServiceName().map(name -> name.equals("aicore")).orElse(false)) - .findFirst(); - // If the AI Core service binding is present, create the AICoreSetup event handler to manage - // resource groups for tenants. - // The binding itself does *not* need to be passed to the AICoreSetup; the AICoreSetup uses - // the com.sap.ai.sdk.core library which reads the binding directly from the environment - // variable AICORE_SERVICE_KEY. - Optional setup = - binding.map(b -> new AICoreSetupHandler(runtime.getEnvironment())); - setup.ifPresent( - s -> { - configurer.eventHandler(s); - logger.info("Registered AICoreSetup as event handler for MTX subscribe/unsubscribe."); - }); - configurer.eventHandler(new FioriRecommendationHandler(setup, persistenceService)); - logger.info("Registered FioriRecommendationHandler for recommendations."); - } -} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java b/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java deleted file mode 100644 index cf06052..0000000 --- a/srv/src/main/java/com/sap/cds/feature/ai/FioriRecommendationHandler.java +++ /dev/null @@ -1,332 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai; - -import com.sap.cds.feature.ai.client.AIClient; -import com.sap.cds.feature.ai.client.AICoreClient; -import com.sap.cds.feature.ai.client.MockAIClient; -import com.sap.cds.feature.ai.client.setup.AICoreSetupHandler; -import com.sap.cds.ql.Select; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.services.cds.ApplicationService; -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.After; -import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.persistence.PersistenceService; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Handler that provides Fiori AI recommendations for entities with value help. This implementation - * adds SAP_Recommendations to draft-enabled entities. - */ -@ServiceName(value = "*", type = ApplicationService.class) -public class FioriRecommendationHandler implements EventHandler { - - private final AIClient aiClient; - private final PersistenceService db; - private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); - 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 ASSOCIATION = "cds.Association"; - private static final String LARGE_BINARY = "cds.LargeBinary"; - private static final String VECTOR = "cds.Vector"; - private static final Set DRAFT_FIELDS = - Set.of( - "HasActiveEntity", - "HasDraftEntity", - "IsActiveEntity", - "DraftAdministrativeData_DraftUUID"); - - public FioriRecommendationHandler(Optional setupOpt, PersistenceService db) { - this.db = db; - if (setupOpt.isPresent()) { - logger.info("Registered AI Service Handler with AI Core setup."); - this.aiClient = new AICoreClient(setupOpt.get()); - } else { - logger.warn( - "No service binding to AI Service found, using mock implementation for Fiori recommendations!"); - this.aiClient = new MockAIClient(); - } - } - - /* - * After read event handler for Fiori AI recommendations: - * - Checks it's a draft read (IsActiveEntity = false), otherwise it's a read-only view and no - * recommendations are needed. - * - Finds all elements with value help annotations, these are the columns we want to predict - * - Fetches up to 2018 existing rows from DB as training context - * - Appends the current row with [PREDICT] as a placeholder - * - Sends the whole batch to the AI client → gets back predictions per row per column - * - Resolves human-readable descriptions for predicted IDs (e.g. genre.name for a predicted genre_ID) - * - Writes predictions into row.put("SAP_Recommendations", {...}) on the live result object, - */ - @After(event = CqnService.EVENT_READ, entity = "*") - public void afterRead(CdsReadEventContext context) { - List rows = context.getResult().listOf(Map.class); - - if (rows.isEmpty()) { - logger.debug("No result found, skipping predictions."); - return; - } - - if (rows.size() > 1) { - logger.debug( - "Multiple entites requested, recommendations are only available for single entity drafts."); - return; - } - - Map row = rows.get(0); - - // Only fetch predictions when editing a draft (IsActiveEntity = false) - Object isActiveEntity = row.get("IsActiveEntity"); - if (!Boolean.FALSE.equals(isActiveEntity)) { - logger.debug( - "Not editing a draft (IsActiveEntity={}), skipping predictions.", isActiveEntity); - return; - } - - // Get all fields that have value help annotations, if there are none, then there are no - // predictions to fetch - // Also Filter out association elements as they are navigation properties and not actual - // columns. - // When @Common.ValueListWithFixedValues is set on an association, CDS - // propagates the annotation to both the association element and its generated scalar - // foreign-key - // field. We only want the scalar fields for prediction, - // as those map to real DB columns and carry the actual values the AI model predicts. - List predictionColumns = - context - .getTarget() - .elements() - .filter( - e -> { - return e.findAnnotation(VALUE_LIST_ANNOTATION).isPresent() - || e.findAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION).isPresent(); - }) - .filter(e -> !e.getType().getQualifiedName().equals(ASSOCIATION)) - .map(e -> e.getName()) - .collect(Collectors.toList()); - if (predictionColumns.isEmpty()) { - logger.debug("No prediction columns found, skipping predictions."); - return; - } - - logger.info( - "Will fetch predictions for entity: " - + context.getTarget().getName() - + " and columns: " - + String.join(", ", predictionColumns)); - - // Columns to include in the context SELECT: exclude BLOBs, Vectors, and draft-only fields - // (mirrors the Node.js filter: type !== 'cds.LargeBinary' && type !== 'cds.Vector') - List contextColumns = - context - .getTarget() - .elements() - .filter(e -> !e.getType().getQualifiedName().equals(LARGE_BINARY)) - .filter(e -> !e.getType().getQualifiedName().equals(VECTOR)) - .filter(e -> !DRAFT_FIELDS.contains(e.getName())) - .filter(e -> !e.getType().getQualifiedName().equals(ASSOCIATION)) - .map(e -> e.getName()) - .collect(Collectors.toList()); - - // Now fetch up to 2018 context rows where all prediction fields are not null - CqnSelect contextQuery = - Select.from(context.getTarget().getQualifiedName()) - .columns(contextColumns.toArray(String[]::new)) - .where( - entity -> { - com.sap.cds.ql.Predicate condition = null; - // Iterate over all prediction columns, add condition that they must be not null - // and concatenate with AND - for (String col : predictionColumns) { - com.sap.cds.ql.Predicate notNull = entity.get(col).isNotNull(); - // concatenate: all columns we want predictions for must not be null - condition = condition == null ? notNull : condition.and(notNull); - } - return condition; - }) - .limit(2018); - List contextRows = new ArrayList<>(db.run(contextQuery).listOf(Map.class)); - if (contextRows.size() < 2) { - logger.info( - "Not enough context rows found with non-null values for prediction columns (minimum is 2), skipping predictions."); - return; - } - - // Add current row with [PREDICT] placeholders; strip draft-only fields first - // (mirrors Node.js: delete predictionRow.DraftAdministrativeData_DraftUUID etc.) - Map predictRow = new HashMap<>(row); - DRAFT_FIELDS.forEach(predictRow::remove); - for (String col : predictionColumns) { - predictRow.putIfAbsent(col, "[PREDICT]"); - } - if (!predictRow.values().contains("[PREDICT]")) { - logger.info( - "Current row already has values for all prediction columns, skipping predictions."); - return; - } - contextRows.add(predictRow); - - // Tenant ID is only needed for multi-tenant applications with AI Core, for single-tenant apps - // it is null, - // yet we need to get it here from the context to pass it to the AI client. - String tenantId = context.getUserInfo().getTenant(); - List> predictions = - aiClient.fetchPredictions(contextRows, predictionColumns, tenantId); - - // For now, we have requested predictions for exactly one row with [PREDICT] placeholders, - // which is why we expect a list of size 1 back from the aiClient. - // In the future, we could also support multiple rows with [PREDICT] placeholders. - if (predictions.isEmpty()) { - logger.warn("No predictions returned from AI client."); - return; - } - if (predictions.size() > 1) { - logger.warn("Multiple predictions returned from AI client, but only one was expected."); - return; - } - Map prediction = predictions.get(0); - - // With the call "fetchPredictions", we get the raw prediction of the format - // { ID : entity_ID, columnName: { prediction: predictionValue }, columnName2: { prediction: - // predictionValue } }. - // - // Left to do: bring the prediction into the below format and put that into the - // "SAP_Recommendations" - // map for the row, which Fiori expects. - // SAP_Recommendations: { - // columnName: { - // RecommendedFieldValue : predictionValue; - // RecommendedFieldIsSuggestion: true; - // RecommendedFieldDescription : "human readable description for the predicted value"; - // RecommendedFieldScoreValue : 0.5; // This number here to rank several predictions per - // column does not matter, since we only have one prediction. - // }, - // columnName2: { ... } - // } - - // To get a human readable recommendation for values where the ID is stored in the main table, - // we build a map for this, i.e. genre_ID -> genre.name, country_code -> country.name, etc. - // The text path is retrieved by stripping off the "_ID" suffix (if there is one) - // and looking for a @Common.Text annotation on the association element (e.g. genre) - // that points to the text field (e.g. genre.name). - Map textPaths = new HashMap<>(); - for (String col : predictionColumns) { - Optional path; - if (col.endsWith("_ID")) { - path = getTextPath(context, col.substring(0, col.length() - 3)); - if (path.isEmpty()) { - path = - getTextPath( - context, - col); // fallback: maybe the ID field itself has the @Common.Text annotation - } - } else { - path = getTextPath(context, col); - } - path.ifPresent(p -> textPaths.put(col, p)); - } - - Map recommendations = new HashMap<>(); - for (String col : predictionColumns) { - Map values = new HashMap<>(); - // Get the recommended value - Object obj = prediction.get(col); - if (obj instanceof List list && !list.isEmpty() && list.get(0) instanceof Map map) { - Object recommendedValue = map.get("prediction"); - // AI always returns strings; try to parse as number - if (recommendedValue instanceof String s) { - try { - recommendedValue = Integer.valueOf(s); - } catch (NumberFormatException ex) { - try { - recommendedValue = Double.valueOf(s); - } catch (NumberFormatException ex2) { - recommendedValue = s; // keep as string - } - } - } - final Object finalValue = recommendedValue; - values.put("RecommendedFieldValue", finalValue); - // Possibly also get the human readable description for that RecommendedFieldValue - values.put( - "RecommendedFieldDescription", - ""); // default to empty string if we cannot find a description - if (textPaths.containsKey( - col)) { // col might be e.g. genre_ID, then textPaths.get(col) could be "genre.name" - String[] parts = textPaths.get(col).split("\\."); // "genre.name" -> ["genre", "name"] - if (parts.length - != 2) { // The expected format for @Common.Text is "association.textField", if it's - // not in this format, we do not resolve the description. - logger.warn( - "Text path {} for column {} is not in expected format 'association.textField', skipping description resolution.", - textPaths.get(col), - col); - continue; - } - CqnSelect descQuery = - Select.from(context.getTarget().getQualifiedName()) - .columns(b -> b.get(col), b -> b.to(parts[0]).get(parts[1])) - .where(b -> b.get(col).eq(finalValue)); - db.run(descQuery) - .forEach( - descRow -> { // this would then return a row with genre_ID and name - Object text = descRow.get(parts[1]); - if (text != null) values.put("RecommendedFieldDescription", text.toString()); - }); - } - values.put( - "RecommendedFieldScoreValue", - 0.5); // If we had multiple predicions, we could use this field to rank them, since we - // only have one prediction, it does not matter what is put here. - values.put( - "RecommendedFieldIsSuggestion", - true); // If we had multiple predicions, we could use this field to select one - // suggestion, since we only have one prediction, it does not matter what is put - // here. - recommendations.put(col, List.of(values)); - } - } - row.put("SAP_Recommendations", recommendations); - } - - private Optional getTextPath(CdsReadEventContext context, String columnName) { - // Get all elements of entity we are looking at - var elements = context.getTarget().elements(); - return elements - .filter(e -> e.getName().equals(columnName)) // Find the element matching the column name - .findFirst() // .flatMap will only unpack the Optional, if we find an element with - // the given column name - .flatMap( - e -> - e.findAnnotation( - "@Common.Text")) // Check if that element has an annotation @Common.Text and get - // its value, e.g. "=": "genre.name" - .flatMap( - a -> { // .flatMap will only unpack the Optional, if we find an annotation - // @Common.Text - Object val = a.getValue(); - // Path annotations can be strings, i.e., "genre.name" or Maps, i.e., {"=": - // "genre.name"} - if (val instanceof String s) return Optional.of(s); - if (val instanceof Map m) { - Object eq = m.get("="); - return eq != null ? Optional.of(eq.toString()) : Optional.empty(); - } - return Optional.empty(); - }); - } -} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/AIClient.java b/srv/src/main/java/com/sap/cds/feature/ai/client/AIClient.java deleted file mode 100644 index cc07d67..0000000 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/AIClient.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai.client; - -import java.util.List; -import java.util.Map; - -public interface AIClient { - /** - * Fetch predictions for the given rows. - * - * @param rows context rows + rows with [PREDICT] placeholders - * @param predictionColumns fields to predict - * @param tenantId the current tenant ID; null in single-tenant mode - * @return predicted values per row - */ - List> fetchPredictions( - List rows, List predictionColumns, String tenantId); -} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java b/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java deleted file mode 100644 index 695c9ed..0000000 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/AICoreClient.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai.client; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.sap.ai.sdk.core.AiCoreService; -import com.sap.ai.sdk.core.JacksonConfiguration; -import com.sap.ai.sdk.foundationmodels.rpt.RptModel; -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.feature.ai.client.setup.AICoreSetupHandler; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class AICoreClient implements AIClient { - - private final AICoreSetupHandler setup; - private static final Logger logger = LoggerFactory.getLogger(AICoreClient.class); - - // Retry inference call when resource group inference endpoint is not yet ready (403) - private static final int INFERENCE_READY_MAX_RETRIES = 8; - private static final long INFERENCE_READY_INITIAL_DELAY_MS = 500; - - // Safety-net: exclude draft/admin fields from the prediction request body. - // BLOB/Vector fields are already filtered upstream in FioriRecommendationHandler. - private static final Set EXCLUDED_FIELDS = - Set.of( - "HasActiveEntity", - "HasDraftEntity", - "IsActiveEntity", - "DraftAdministrativeData_DraftUUID", - "createdBy", - "modifiedBy", - "createdAt", - "modifiedAt"); - - public AICoreClient(AICoreSetupHandler setup) { - this.setup = setup; - } - - @Override - /* - * Prediction flow: - * 1. resolveResourceGroup(tenantId) to get the AI Core resource group for the tenant - * 2. call predict() with the resource group, input rows and prediction columns - */ - public List> fetchPredictions( - List rows, List predictionColumns, String tenantId) { - try { - String resourceGroup = setup.resolveResourceGroup(tenantId); - return predict(resourceGroup, rows, predictionColumns); - } catch (Exception e) { - logger.error("Failed to fetch predictions from AI Core", e); - throw new RuntimeException("Failed to fetch predictions from AI Core", e); - } - } - - /* - * As described in https://sap.github.io/ai-sdk/docs/java/foundation-models/sap-rpt/table-completion#simple-table-completion - * 1. Build a PredictRequestPayload with the input rows and prediction column config - * 2. Call the AI Core API to get predictions - * 3. Parse and return the predictions as List> - */ - private List> predict( - String resourceGroup, List rows, List predictionColumns) { - var targetColumns = - predictionColumns.stream() - .map( - col -> - TargetColumnConfig.create() - .name(col) - .predictionPlaceholder(PredictionPlaceholder.create("[PREDICT]")) - .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) - .collect(Collectors.toList()); - - var sdkRows = - rows.stream() - .map( - row -> { - Map sdkRow = new LinkedHashMap<>(); - row.forEach( - (k, v) -> { - if (!EXCLUDED_FIELDS.contains(k) - && v != null - && (v instanceof String - || v instanceof Number - || v instanceof Boolean)) { - sdkRow.put(k.toString(), RowsInnerValue.create(v.toString())); - } - }); - return sdkRow; - }) - .collect(Collectors.toList()); - - var request = - PredictRequestPayload.create() - .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) - .rows(sdkRows) - .indexColumn("ID"); - - logger.debug( - "Sending prediction request for {} rows, {} target columns", - sdkRows.size(), - targetColumns.size()); - - // In multi-tenant mode, we manage the RPT-1 deployment lifecycle per resource group ourselves - // (create on subscribe, delete on unsubscribe), so we resolve the deployment ID explicitly. - // In single-tenant mode, we let the SDK resolve the deployment via forModel(), which queries - // AI Core for any running RPT-1 deployment in the resource group. - - // In multi-tenant mode, we cannot use RptClient.forModel(), because it calls - // AiCoreService().getInferenceDestination() with no arguments, - // which always resolves to the "default" resource group. Instead, we replicate - // the logic from RptClient using our per-tenant resource group, with the same arguments, i.e., - // JacksonConfiguration.getDefaultObjectMapper() and the default header "Content-Encoding: - // gzip". - System.out.println("Resolving inference destination for resource group: " + resourceGroup); - System.out.println( - "AICoreSetup.isMultitenancyEnabled() = " + AICoreSetupHandler.isMultitenancyEnabled()); - var inferenceBuilder = new AiCoreService().getInferenceDestination(resourceGroup); - var model = RptModel.SAP_RPT_1_SMALL; - System.out.println("Using model: " + model.name() + ", deployment: " + inferenceBuilder); - var destination = - AICoreSetupHandler.isMultitenancyEnabled() - ? inferenceBuilder.usingDeploymentId(setup.getDeploymentForResourceGroup(resourceGroup)) - : inferenceBuilder.forModel(RptModel.SAP_RPT_1_SMALL); - var apiClient = - ApiClient.create(destination) - .withObjectMapper(JacksonConfiguration.getDefaultObjectMapper()); - var api = new DefaultApi(apiClient).withDefaultHeaders(Map.of("Content-Encoding", "gzip")); - - // AI Core inference endpoints for freshly created resource groups may return 403 - // until the endpoint is fully provisioned — retry with exponential backoff. - long delay = INFERENCE_READY_INITIAL_DELAY_MS; - for (int i = 0; i < INFERENCE_READY_MAX_RETRIES; i++) { - try { - var response = api.predict(request); - logger.debug("Prediction response id: {}", response.getId()); - try { - return JacksonConfiguration.getDefaultObjectMapper() - .convertValue(response.getPredictions(), new TypeReference<>() {}); - } catch (Exception e) { - throw new RuntimeException("Failed to parse prediction response", e); - } - } catch (OpenApiRequestException e) { - if (AICoreSetupHandler.notReadyYet(e) && i < INFERENCE_READY_MAX_RETRIES - 1) { - logger.debug( - "Inference endpoint for resource group {} not ready yet (403), retrying in {} ms ({}/{})", - resourceGroup, - delay, - i + 1, - INFERENCE_READY_MAX_RETRIES); - try { - Thread.sleep(delay); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Interrupted while waiting for inference endpoint", ie); - } - delay *= 2; - } else { - throw e; - } - } - } - throw new IllegalStateException("predict() exited retry loop unexpectedly"); - } -} diff --git a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandler.java b/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandler.java deleted file mode 100644 index 2249167..0000000 --- a/srv/src/main/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandler.java +++ /dev/null @@ -1,372 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai.client.setup; - -import com.sap.ai.sdk.core.client.ConfigurationApi; -import com.sap.ai.sdk.core.client.DeploymentApi; -import com.sap.ai.sdk.core.client.ResourceGroupApi; -import com.sap.ai.sdk.core.model.AiConfiguration; -import com.sap.ai.sdk.core.model.AiConfigurationBaseData; -import com.sap.ai.sdk.core.model.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.AiDeploymentStatus; -import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; -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.services.environment.CdsEnvironment; -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 java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@ServiceName(DeploymentService.DEFAULT_NAME) -public class AICoreSetupHandler implements EventHandler { - - private static final String DEFAULT_RESOURCE_GROUP = "default"; - private static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; - private static final String RPT_SCENARIO_ID = "foundation-models"; - private static final String RPT_EXECUTABLE_ID = "aicore-sap"; - private static final String RPT_CONFIG_NAME = "sap-rpt-1-small"; - private static final String RPT_MODEL_NAME = "sap-rpt-1-small"; - private static final String RPT_MODEL_VERSION = "latest"; - // Max retries and initial delay (ms) when polling for RUNNING of deployment or readiness of - // resource group - private static final int AICORE_OPS_MAX_RETRIES = 10; - private static final long AICORE_OPS_INITIAL_DELAY_MS = 300; - private static final Logger logger = LoggerFactory.getLogger(AICoreSetupHandler.class); - - // In-memory cache: tenantId -> resourceGroupId - private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); - // In-memory cache: resourceGroupId -> RPT-1 deploymentId - private final Map resourceGroupDeploymentCache = new ConcurrentHashMap<>(); - - private final CdsEnvironment environment; - - // For testing - Map getTenantResourceGroupCache() { - return tenantResourceGroupCache; - } - - Map getResourceGroupDeploymentCache() { - return resourceGroupDeploymentCache; - } - - public AICoreSetupHandler(CdsEnvironment environment) { - this.environment = environment; - } - - /** - * Called automatically after a tenant subscribes: Creates an AI Core resource group for the - * tenant. - */ - @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 = getResourceGroupForTenant(tenantId); - logger.info("Created AI Core resource group {} for tenant {}", resourceGroupId, tenantId); - } catch (Exception e) { - // Don't throw - let subscription succeed - logger.error( - "Failed to create AI Core resources for tenant {} (retrying on demand)", tenantId, e); - } - } - - /** - * Called automatically before a tenant unsubscribes: Deletes the AI Core resource group for the - * tenant. - */ - @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); - logger.info("Deleted AI Core resources for tenant {}", tenantId); - } catch (Exception e) { - // Don't throw - let unsubscription succeed - logger.warn("Failed to delete AI Core resources for tenant {}: {}", tenantId, e.getMessage()); - } - } - - public static boolean isMultitenancyEnabled() { - return Boolean.parseBoolean( - System.getProperty( - "cds.multitenancy.enabled", - System.getenv().getOrDefault("CDS_MULTITENANCY_ENABLED", "false"))); - // this.environment.getProperty("cds.requires.multitenancy", Boolean.class, false)); - } - - /** - * Resolves the resource group for the given tenant. In multi-tenant mode, checks for an existing - * resource group or creates one. In single-tenant mode, returns the default resource group. - */ - public String resolveResourceGroup(String tenantId) { - if (isMultitenancyEnabled()) { - return getResourceGroupForTenant(tenantId); - } - String group = - this.environment.getProperty( - "cds.requires.AICore.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); - group = group != null ? group : DEFAULT_RESOURCE_GROUP; - logger.info("Multitenancy disabled, using resource group {}", group); - return group; - } - - /** - * Returns the resource group for a tenant, creating one in AI Core if it doesn't exist yet. - * Caches the result in memory. - */ - private String getResourceGroupForTenant(String tenantId) { - String cached = tenantResourceGroupCache.get(tenantId); - if (cached != null) { - return cached; - } - ResourceGroupApi api = new ResourceGroupApi(); - List labelSelector = List.of(TENANT_LABEL_KEY + "=" + tenantId); - BckndResourceGroupList result = api.getAll(null, null, null, null, null, null, labelSelector); - List resources = result.getResources(); - if (resources != null && !resources.isEmpty()) { - String resourceGroupId = resources.get(0).getResourceGroupId(); - tenantResourceGroupCache.put(tenantId, resourceGroupId); - return resourceGroupId; - } - String createdId = createResourceGroupForTenant(tenantId, api); - tenantResourceGroupCache.put(tenantId, createdId); - return createdId; - } - - // See - // https://javadoc.io/doc/com.sap.ai.sdk/core/latest/com/sap/ai/sdk/core/model/BckndResourceGroupsPostRequest.html - private String createResourceGroupForTenant(String tenantId, ResourceGroupApi api) { - // This resourceGroupId is needed for the request, will fail otherwise - String resourceGroupId = UUID.randomUUID().toString(); - BckndResourceGroupLabel label = - BckndResourceGroupLabel.create().key(TENANT_LABEL_KEY).value(tenantId); - BckndResourceGroupsPostRequest request = - BckndResourceGroupsPostRequest.create() - .resourceGroupId(resourceGroupId) - .labels(List.of(label)); - api.create(request); - logger.debug("Created resource group {} for tenant {}", resourceGroupId, tenantId); - return resourceGroupId; - } - - /** - * Returns the RPT-1 deployment ID for the given resource group, creating configuration and - * deployment if none exists. Polls until the deployment reaches RUNNING status, which might take - * a while. Caches the result in memory. - */ - public String getDeploymentForResourceGroup(String resourceGroup) { - String cached = resourceGroupDeploymentCache.get(resourceGroup); - if (cached != null) { - return cached; - } - DeploymentApi deploymentApi = new DeploymentApi(); - // Look for an existing running or pending RPT-1 deployment in this resource group. - AiDeploymentList deploymentList = - queryDeploymentsFromResourceGroupUntilReady(deploymentApi, resourceGroup); - Optional aiDeployment = - deploymentList.getResources().stream() - .filter( - d -> - RPT_CONFIG_NAME.equals(d.getConfigurationName()) - && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) - || AiDeploymentStatus.PENDING.equals(d.getStatus()))) - .findFirst(); - if (aiDeployment.isPresent()) { - String deploymentId = aiDeployment.get().getId(); - resourceGroupDeploymentCache.put(resourceGroup, deploymentId); - return deploymentId; - } - - // No deployment found: we check if there is a configuration, if not create one and then create - // a deployment. - // The resource group should be ready for this call, since - // queryDeploymentsFromResourceGroupUntilReady made sure it is. - ConfigurationApi configApi = new ConfigurationApi(); - AiConfigurationList configList = - configApi.query(resourceGroup, RPT_SCENARIO_ID, null, null, null, null, null, null); - Optional existingConfig = - configList.getResources().stream() - .filter(c -> RPT_CONFIG_NAME.equals(c.getName())) - .findFirst(); - - String configId; - if (existingConfig.isPresent()) { - configId = existingConfig.get().getId(); - logger.debug( - "Reusing existing RPT-1 configuration {} in resource group {}", configId, resourceGroup); - } else { - // Configuration creation is synchronous and should be fast, so we don't implement a retry - // loop here. - AiConfigurationBaseData configRequest = - AiConfigurationBaseData.create() - .name(RPT_CONFIG_NAME) - .executableId(RPT_EXECUTABLE_ID) - .scenarioId(RPT_SCENARIO_ID); - configRequest.parameterBindings( - List.of( - AiParameterArgumentBinding.create().key("modelName").value(RPT_MODEL_NAME), - AiParameterArgumentBinding.create().key("modelVersion").value(RPT_MODEL_VERSION))); - configId = configApi.create(resourceGroup, configRequest).getId(); - logger.debug("Created RPT-1 configuration {} in resource group {}", configId, resourceGroup); - } - - // Now create a deployment for the configuration and poll until it's running and usable. - long delay = AICORE_OPS_INITIAL_DELAY_MS; - for (int i = 0; i < AICORE_OPS_MAX_RETRIES; i++) { - try { - var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); - var deployResponse = deploymentApi.create(resourceGroup, deployRequest); - String deploymentId = deployResponse.getId(); - logger.debug( - "Created RPT-1 deployment {} in resource group {}, polling for RUNNING", - deploymentId, - resourceGroup); - - return pollUntilRunning(deploymentApi, resourceGroup, deploymentId); - } catch (OpenApiRequestException e) { - if (notReadyYet(e) && i < AICORE_OPS_MAX_RETRIES - 1) { - logger.debug( - "Deployment of resource group {} not ready yet ({}), retrying in {} ms ({}/{})", - resourceGroup, - e.getMessage(), - delay, - i + 1, - AICORE_OPS_MAX_RETRIES); - try { - Thread.sleep(delay); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException( - "Interrupted while waiting for resource group " + resourceGroup, ie); - } - delay *= 2; - } else { - throw e; - } - } - } - throw new IllegalStateException( - "Resource group " + resourceGroup + " never became ready for deployment"); - } - - private String pollUntilRunning( - DeploymentApi deploymentApi, String resourceGroup, String deploymentId) { - long delay = AICORE_OPS_INITIAL_DELAY_MS; - for (int i = 0; i < AICORE_OPS_MAX_RETRIES; i++) { - try { - Thread.sleep(delay); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Interrupted while waiting for deployment " + deploymentId, e); - } - var current = deploymentApi.get(resourceGroup, deploymentId); - logger.debug( - "Deployment {} status: {} (retry {}/{})", - deploymentId, - current.getStatus(), - i + 1, - AICORE_OPS_MAX_RETRIES); - if (AiDeploymentStatus.RUNNING.equals(current.getStatus())) { - resourceGroupDeploymentCache.put(resourceGroup, deploymentId); - return deploymentId; - } - delay *= 2; - } - throw new RuntimeException( - "RPT-1 deployment " - + deploymentId - + " did not reach RUNNING status after " - + AICORE_OPS_MAX_RETRIES - + " retries"); - } - - /* - * Queries deployments in the given resource group. - * In case the resource group isn't ready yet, i.e., the request returns a 403 or 412 status code, - * we query until the resource group becomes ready; this is neccessary for further interaction with - * the resource group. - */ - private AiDeploymentList queryDeploymentsFromResourceGroupUntilReady( - DeploymentApi deploymentApi, String resourceGroup) { - long delay = AICORE_OPS_INITIAL_DELAY_MS; - for (int i = 0; i < AICORE_OPS_MAX_RETRIES; i++) { - try { - return deploymentApi.query( - resourceGroup, null, null, RPT_SCENARIO_ID, null, null, null, null); - } catch (OpenApiRequestException e) { - if (notReadyYet(e) && i < AICORE_OPS_MAX_RETRIES - 1) { - logger.debug( - "Resource group {} not ready yet ({}), retrying in {} ms ({}/{})", - resourceGroup, - e.getMessage(), - delay, - i + 1, - AICORE_OPS_MAX_RETRIES); - try { - Thread.sleep(delay); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException( - "Interrupted while waiting for resource group " + resourceGroup, ie); - } - delay *= 2; - } else { - throw e; - } - } - } - // unreachable — loop always returns or throws - throw new IllegalStateException("queryDeploymentsWithRetry exited unexpectedly"); - } - - /** - * Returns true if the exception (or its cause chain) represents an HTTP 403 or 412. AI Core - * returns 403 or 412 when a deployment is not yet provisioned or ready. In this case, we retry - * the operation that caused the exception after a delay. The SDK sometimes wraps the actual - * OpenApiRequestException in an IOException which is why we check the nested causes. - */ - public static boolean notReadyYet(OpenApiRequestException e) { - Throwable t = e; - while (t != null) { - if (t instanceof OpenApiRequestException oae) { - Integer code = oae.statusCode(); - if (Integer.valueOf(403).equals(code) || Integer.valueOf(412).equals(code)) { - return true; - } - } - t = t.getCause(); - } - return false; - } - - private void deleteResourceGroupForTenant(String tenantId) { - String resourceGroupId = tenantResourceGroupCache.remove(tenantId); - if (resourceGroupId == null) { - logger.debug("No cached resource group for tenant {}, nothing to delete", tenantId); - return; - } - resourceGroupDeploymentCache.remove(resourceGroupId); - new ResourceGroupApi().delete(resourceGroupId); - logger.info("Deleted resource group {} for tenant {}", resourceGroupId, tenantId); - } -} diff --git a/srv/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/srv/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration deleted file mode 100644 index bb6e1d3..0000000 --- a/srv/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration +++ /dev/null @@ -1 +0,0 @@ -com.sap.cds.feature.ai.AIRuntimeConfiguration \ No newline at end of file diff --git a/srv/src/test/java/com/sap/cds/feature/ai/FioriRecommendationHandlerTest.java b/srv/src/test/java/com/sap/cds/feature/ai/FioriRecommendationHandlerTest.java deleted file mode 100644 index 3024268..0000000 --- a/srv/src/test/java/com/sap/cds/feature/ai/FioriRecommendationHandlerTest.java +++ /dev/null @@ -1,299 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -import com.sap.cds.Result; -import com.sap.cds.ResultBuilder; -import com.sap.cds.feature.ai.client.MockAIClient; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.reflect.CdsAnnotation; -import com.sap.cds.reflect.CdsElement; -import com.sap.cds.reflect.CdsEntity; -import com.sap.cds.reflect.CdsType; -import com.sap.cds.services.cds.CdsReadEventContext; -import com.sap.cds.services.persistence.PersistenceService; -import com.sap.cds.services.request.UserInfo; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedConstruction; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class FioriRecommendationHandlerTest { - - @Mock private PersistenceService db; - @Mock private CdsReadEventContext context; - @Mock private CdsEntity entity; - @Mock private UserInfo userInfo; - - private FioriRecommendationHandler cut; - - @BeforeEach - void setup() { - cut = new FioriRecommendationHandler(Optional.empty(), db); - } - - @Test - void emptyRows_returnsEarlyWithoutPredictions() { - when(context.getResult()).thenReturn(result()); - cut.afterRead(context); - verifyNoInteractions(db); - } - - @Test - void multipleRows_returnsEarlyWithoutPredictions() { - when(context.getResult()) - .thenReturn( - result( - Map.of("ID", "1", "IsActiveEntity", false), - Map.of("ID", "2", "IsActiveEntity", false))); - cut.afterRead(context); - verifyNoInteractions(db); - } - - @Test - void activeEntity_returnsEarlyWithoutPredictions() { - when(context.getResult()).thenReturn(result(Map.of("ID", "1", "IsActiveEntity", true))); - cut.afterRead(context); - verifyNoInteractions(db); - } - - @Test - void noPredictionColumns_returnsEarlyWithoutPredictions() { - // association-typed element: passes ValueList filter but is excluded by the Association filter - CdsElement assocEl = mock(CdsElement.class); - when(assocEl.findAnnotation("@Common.ValueList")) - .thenReturn(Optional.of(mock(CdsAnnotation.class))); - CdsType type = mock(CdsType.class); - when(type.getQualifiedName()).thenReturn("cds.Association"); - when(assocEl.getType()).thenReturn(type); - when(context.getTarget()).thenReturn(entity); - when(entity.elements()).thenAnswer(inv -> java.util.stream.Stream.of(assocEl)); - when(context.getResult()).thenReturn(result(draftRow("title", "foo"))); - cut.afterRead(context); - verifyNoInteractions(db); - } - - @Test - void notEnoughContextRows_returnsEarlyWithoutPredictions() { - setupEntity(genreIdEl()); - Map row = draftRow("genre_ID", null); - when(context.getResult()).thenReturn(result(row)); - when(db.run(any(CqnSelect.class))) - .thenReturn( - ResultBuilder.selectedRows(List.of(Map.of("ID", "x1", "genre_ID", 12))).result()); - cut.afterRead(context); - assertThat(row).doesNotContainKey("SAP_Recommendations"); - } - - @Test - void allColumnsAlreadyFilled_returnsEarlyWithoutPredictions() { - setupEntity(genreIdEl()); - Map row = draftRow("genre_ID", 16); - when(context.getResult()).thenReturn(result(row)); - when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - cut.afterRead(context); - assertThat(row).doesNotContainKey("SAP_Recommendations"); - } - - @Test - void emptyPredictions_returnsEarlyWithoutRecommendations() { - setupEntityWithUserInfo(genreIdEl()); - Map row = draftRow("genre_ID", null); - when(context.getResult()).thenReturn(result(row)); - when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - try (MockedConstruction ignored = - mockConstruction( - MockAIClient.class, - (mock, ctx) -> - when(mock.fetchPredictions(any(), any(), any())).thenReturn(List.of()))) { - cut = new FioriRecommendationHandler(Optional.empty(), db); - cut.afterRead(context); - } - assertThat(row).doesNotContainKey("SAP_Recommendations"); - } - - @Test - void multiplePredictions_returnsEarlyWithoutRecommendations() { - setupEntityWithUserInfo(genreIdEl()); - Map row = draftRow("genre_ID", null); - when(context.getResult()).thenReturn(result(row)); - when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - try (MockedConstruction ignored = - mockConstruction( - MockAIClient.class, - (mock, ctx) -> - when(mock.fetchPredictions(any(), any(), any())) - .thenReturn(List.of(Map.of("ID", "id-1"), Map.of("ID", "id-2"))))) { - cut = new FioriRecommendationHandler(Optional.empty(), db); - cut.afterRead(context); - } - assertThat(row).doesNotContainKey("SAP_Recommendations"); - } - - @Test - @SuppressWarnings({"unchecked", "rawtypes"}) - void draftRow_withGenreAndCurrency_addsSapRecommendations() { - 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); - CdsAnnotation textAnn = mock(CdsAnnotation.class); - when(textAnn.getValue()).thenReturn("genre.name"); - setupEntityWithUserInfo( - element("genre_ID", "cds.Integer", true, Optional.empty()), - element("currency_code", "cds.String", true, Optional.empty()), - element("genre", "cds.Association", false, Optional.of(textAnn))); - when(context.getResult()).thenReturn(result(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()); - cut.afterRead(context); - 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 - @SuppressWarnings("unchecked") - void textAnnotation_asMap_withEqKey_resolvesTextPath() { - CdsAnnotation textAnn = mock(CdsAnnotation.class); - when(textAnn.getValue()).thenReturn(Map.of("=", "genre.name")); - setupEntityWithUserInfo( - genreIdEl(), element("genre", "cds.Association", false, Optional.of(textAnn))); - Map row = draftRow("genre_ID", null); - when(context.getResult()).thenReturn(result(row)); - when(db.run(any(CqnSelect.class))) - .thenReturn(twoContextRows(), ResultBuilder.selectedRows(List.of()).result()); - cut.afterRead(context); - assertThat((Map) row.get("SAP_Recommendations")).containsKey("genre_ID"); - } - - @Test - @SuppressWarnings("unchecked") - void textAnnotation_asMap_withoutEqKey_noDescription() { - CdsAnnotation textAnn = mock(CdsAnnotation.class); - when(textAnn.getValue()).thenReturn(Map.of("other", "value")); - setupEntityWithUserInfo( - genreIdEl(), element("genre", "cds.Association", false, Optional.of(textAnn))); - Map row = draftRow("genre_ID", null); - when(context.getResult()).thenReturn(result(row)); - when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - cut.afterRead(context); - Map recs = (Map) row.get("SAP_Recommendations"); - assertThat(((List>) recs.get("genre_ID")).get(0)) - .containsEntry("RecommendedFieldDescription", ""); - } - - @Test - @SuppressWarnings("unchecked") - void textPath_invalidFormat_skipsColumn() { - CdsAnnotation textAnn = mock(CdsAnnotation.class); - when(textAnn.getValue()).thenReturn("genre.parent.name"); - setupEntityWithUserInfo( - genreIdEl(), element("genre", "cds.Association", false, Optional.of(textAnn))); - Map row = draftRow("genre_ID", null); - when(context.getResult()).thenReturn(result(row)); - when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - cut.afterRead(context); - assertThat((Map) row.get("SAP_Recommendations")).doesNotContainKey("genre_ID"); - } - - @Test - void blobAndVectorFields_areExcludedFromContextSelect() { - // Arrange: entity with a predictable genre_ID, a LargeBinary (image), and a Vector field - setupEntityWithUserInfo( - genreIdEl(), - element("image", "cds.LargeBinary", false, Optional.empty()), - element("embedding", "cds.Vector", false, Optional.empty())); - Map row = draftRow("genre_ID", null); - when(context.getResult()).thenReturn(result(row)); - - ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); - when(db.run(selectCaptor.capture())).thenReturn(twoContextRows()); - - cut.afterRead(context); - - // The SELECT sent to db.run() must not contain the BLOB or Vector column names - String selectSql = selectCaptor.getValue().toString(); - assertThat(selectSql).doesNotContain("image"); - assertThat(selectSql).doesNotContain("embedding"); - assertThat(selectSql).contains("genre_ID"); - } - - // ── helpers ──────────────────────────────────────────────────────────────── - - @SafeVarargs - private static Result result(Map... rows) { - return ResultBuilder.selectedRows(List.of(rows)).result(); - } - - private Map draftRow(String col, Object val) { - Map row = new HashMap<>(); - row.put("ID", "id-1"); - row.put("IsActiveEntity", false); - row.put(col, val); - return row; - } - - private Result twoContextRows() { - return ResultBuilder.selectedRows( - new ArrayList<>( - List.of(Map.of("ID", "x1", "genre_ID", 12), Map.of("ID", "x2", "genre_ID", 16)))) - .result(); - } - - private CdsElement genreIdEl() { - return element("genre_ID", "cds.Integer", true, Optional.empty()); - } - - private void setupEntity(CdsElement... elements) { - when(context.getTarget()).thenReturn(entity); - lenient().when(entity.getName()).thenReturn("Books"); - lenient().when(entity.getQualifiedName()).thenReturn("bookshop.Books"); - when(entity.elements()).thenAnswer(inv -> java.util.stream.Stream.of(elements)); - } - - private void setupEntityWithUserInfo(CdsElement... elements) { - setupEntity(elements); - when(context.getUserInfo()).thenReturn(userInfo); - when(userInfo.getTenant()).thenReturn(null); // can be null, but must be callabe - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private static CdsElement element( - String name, String cdsType, boolean hasValueList, Optional textAnnotation) { - CdsElement el = mock(CdsElement.class); - when(el.getName()).thenReturn(name); - CdsType type = mock(CdsType.class); - lenient().when(type.getQualifiedName()).thenReturn(cdsType); - lenient().when(el.getType()).thenReturn(type); - when(el.findAnnotation("@Common.ValueList")).thenReturn(Optional.empty()); - Optional valueListAnn = - hasValueList ? Optional.of(mock(CdsAnnotation.class)) : Optional.empty(); - when(el.findAnnotation("@Common.ValueListWithFixedValues")).thenReturn(valueListAnn); - lenient().when(el.findAnnotation("@Common.Text")).thenReturn((Optional) textAnnotation); - return el; - } -} diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java deleted file mode 100644 index bdf43a0..0000000 --- a/srv/src/test/java/com/sap/cds/feature/ai/client/AICoreClientPredictionTest.java +++ /dev/null @@ -1,232 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai.client; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.ai.client.setup.AICoreSetupHandler; -import com.sap.cds.services.environment.CdsEnvironment; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.HashMap; -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.mockito.Mockito; - -/** - * Integration test verifying the full prediction flow against a real AI Core instance. - * - *

Required environment variables: AICORE_SERVICE_KEY – Full AI Core service key JSON: { - * "clientid": "...", "clientsecret": "...", "url": "...", "serviceurls": { "AI_API_URL": "..." } } - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class AICoreClientPredictionTest { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private AICoreClient cut; - private Map credentials; - private String resourceGroup; - - @BeforeAll - void setup() throws Exception { - String serviceKey = System.getenv("AICORE_SERVICE_KEY"); - assumeTrue(serviceKey != null, "Skipping integration test: AICORE_SERVICE_KEY env var not set"); - - credentials = MAPPER.readValue(serviceKey, new TypeReference<>() {}); - resourceGroup = "default"; - CdsEnvironment environment = Mockito.mock(CdsEnvironment.class); - cut = new AICoreClient(new AICoreSetupHandler(environment)); - } - - /** - * Full prediction flow: 1. Check deployments in the resource group 2. If no RUNNING rpt-1 - * deployment exists, create one and wait for it to reach RUNNING 3. Call fetchPredictions with a - * small context row and a [PREDICT] row 4. Verify predictions are returned - */ - @Test - void prediction_deploymentMissingOrPresent_returnsPredictions() throws Exception { - String aiApiUrl = ((Map) credentials.get("serviceurls")).get("AI_API_URL").toString(); - String token = fetchToken(); - - // Step 1+2: ensure a RUNNING rpt-1 deployment exists (create if missing) - ensureRunningRpt1Deployment(aiApiUrl, token); - - // Step 3: build rows — two context rows + one [PREDICT] row - Map contextRow1 = new HashMap<>(); - contextRow1.put("ID", "ctx-1"); - contextRow1.put("genre_ID", 10); - contextRow1.put("title", "Eleonora"); - - Map contextRow2 = new HashMap<>(); - contextRow2.put("ID", "ctx-2"); - contextRow2.put("genre_ID", 20); - contextRow2.put("title", "Another Book"); - - Map predictRow = new HashMap<>(); - predictRow.put("ID", "predict-1"); - predictRow.put("genre_ID", "[PREDICT]"); - predictRow.put("title", "Eleonora"); - - List> predictions = - cut.fetchPredictions( - List.of(contextRow1, contextRow2, predictRow), List.of("genre_ID"), null); - - // Step 4: verify - assertThat(predictions) - .as("Should return at least one prediction result") - .isNotNull() - .isNotEmpty(); - - Map prediction = predictions.get(0); - assertThat(prediction).containsKey("genre_ID"); - } - - // ── helpers ────────────────────────────────────────────────────────────── - - /** - * Checks whether a RUNNING rpt-1 deployment exists. If not, creates one and polls until it - * reaches RUNNING (up to ~5 minutes with exponential back-off). - */ - private void ensureRunningRpt1Deployment(String aiApiUrl, String token) throws Exception { - HttpClient client = HttpClient.newHttpClient(); - - // Check existing deployments - HttpResponse listResponse = - client.send( - HttpRequest.newBuilder() - .uri(URI.create(aiApiUrl + "/v2/lm/deployments")) - .header("Authorization", "Bearer " + token) - .header("AI-Resource-Group", resourceGroup) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - - Map listResult = - MAPPER.readValue(listResponse.body(), new TypeReference<>() {}); - List> deployments = - MAPPER.convertValue(listResult.get("resources"), new TypeReference<>() {}); - - boolean hasRunning = - deployments != null - && deployments.stream() - .anyMatch( - d -> - d.get("configurationName") != null - && d.get("configurationName").toString().contains("rpt-1") - && "RUNNING".equals(d.get("status"))); - - if (hasRunning) { - return; // nothing to do - } - - // Find the rpt-1 configuration - HttpResponse cfgResponse = - client.send( - HttpRequest.newBuilder() - .uri(URI.create(aiApiUrl + "/v2/lm/configurations")) - .header("Authorization", "Bearer " + token) - .header("AI-Resource-Group", resourceGroup) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - - Map cfgResult = MAPPER.readValue(cfgResponse.body(), new TypeReference<>() {}); - List> configurations = - MAPPER.convertValue(cfgResult.get("resources"), new TypeReference<>() {}); - assertThat(configurations) - .as( - "No rpt-1 configuration found in resource group " - + resourceGroup - + " — cannot create a deployment. Ensure the configuration exists.") - .isNotNull() - .isNotEmpty(); - - String configurationId = - configurations.stream() - .filter(c -> c.get("name") != null && c.get("name").toString().contains("rpt-1")) - .map(c -> c.get("id").toString()) - .findFirst() - .orElseThrow( - () -> - new AssertionError( - "No rpt-1 configuration found in resource group: " + resourceGroup)); - - // Create the deployment - String createBody = MAPPER.writeValueAsString(Map.of("configurationId", configurationId)); - HttpResponse createResponse = - client.send( - HttpRequest.newBuilder() - .uri(URI.create(aiApiUrl + "/v2/lm/deployments")) - .header("Authorization", "Bearer " + token) - .header("Content-Type", "application/json") - .header("AI-Resource-Group", resourceGroup) - .POST(HttpRequest.BodyPublishers.ofString(createBody)) - .build(), - HttpResponse.BodyHandlers.ofString()); - - Map created = MAPPER.readValue(createResponse.body(), new TypeReference<>() {}); - String deploymentId = created.get("id").toString(); - - // Poll until RUNNING - for (int i = 0; i < 10; i++) { - long delay = 300L * (1L << i); - System.out.printf( - "Waiting %dms for deployment %s to reach RUNNING (attempt %d/10)%n", - delay, deploymentId, i + 1); - Thread.sleep(delay); - - token = fetchToken(); - HttpResponse statusResponse = - client.send( - HttpRequest.newBuilder() - .uri(URI.create(aiApiUrl + "/v2/lm/deployments/" + deploymentId)) - .header("Authorization", "Bearer " + token) - .header("AI-Resource-Group", resourceGroup) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - - Map status = - MAPPER.readValue(statusResponse.body(), new TypeReference<>() {}); - if ("RUNNING".equals(status.get("status"))) { - return; - } - } - throw new AssertionError( - "Deployment " + deploymentId + " did not reach RUNNING within timeout"); - } - - /** Fetches a fresh OAuth token using service key credentials. */ - private String fetchToken() throws Exception { - String body = - "grant_type=client_credentials" - + "&client_id=" - + URLEncoder.encode(credentials.get("clientid").toString(), "UTF-8") - + "&client_secret=" - + URLEncoder.encode(credentials.get("clientsecret").toString(), "UTF-8"); - - HttpResponse response = - HttpClient.newHttpClient() - .send( - HttpRequest.newBuilder() - .uri(URI.create(credentials.get("url") + "/oauth/token")) - .header("Content-Type", "application/x-www-form-urlencoded") - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(), - HttpResponse.BodyHandlers.ofString()); - - Map data = MAPPER.readValue(response.body(), new TypeReference<>() {}); - return data.get("access_token").toString(); - } -} diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/MockAIClientTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/MockAIClientTest.java deleted file mode 100644 index b9649ec..0000000 --- a/srv/src/test/java/com/sap/cds/feature/ai/client/MockAIClientTest.java +++ /dev/null @@ -1,146 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai.client; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class MockAIClientTest { - - private MockAIClient cut; - - @BeforeEach - void setup() { - cut = new MockAIClient(); - } - - @Test - void noRowsReturnsEmpty() { - List> result = - cut.fetchPredictions(new ArrayList<>(), List.of("genre_ID"), null); - assertThat(result).isEmpty(); - } - - @Test - void rowWithoutPredictPlaceholderIsSkipped() { - Map row = new HashMap<>(); - row.put("ID", "id-1"); - row.put("genre_ID", 10); - - List> result = - cut.fetchPredictions(List.of(row), List.of("genre_ID"), null); - - assertThat(result).isEmpty(); - } - - @Test - void rowWithPredictPlaceholderReturnsPrediction() { - Map contextRow = new HashMap<>(); - contextRow.put("ID", "id-1"); - contextRow.put("genre_ID", 10); - - Map predictRow = new HashMap<>(); - predictRow.put("ID", "id-2"); - predictRow.put("genre_ID", "[PREDICT]"); - - List> result = - cut.fetchPredictions(List.of(contextRow, predictRow), List.of("genre_ID"), null); - - assertThat(result).hasSize(1); - assertThat(result.get(0).get("ID")).isEqualTo("id-2"); - - @SuppressWarnings("unchecked") - List> preds = (List>) result.get(0).get("genre_ID"); - assertThat(preds).hasSize(1); - assertThat(preds.get(0).get("prediction")).isEqualTo(10); - } - - @Test - void predictionPicksValueFromContextRows() { - Map c1 = new HashMap<>(); - c1.put("ID", "id-1"); - c1.put("genre_ID", 10); - - Map c2 = new HashMap<>(); - c2.put("ID", "id-2"); - c2.put("genre_ID", 20); - - Map predictRow = new HashMap<>(); - predictRow.put("ID", "id-3"); - predictRow.put("genre_ID", "[PREDICT]"); - - List> result = - cut.fetchPredictions(List.of(c1, c2, predictRow), List.of("genre_ID"), null); - - assertThat(result).hasSize(1); - @SuppressWarnings("unchecked") - List> preds = (List>) result.get(0).get("genre_ID"); - assertThat(preds.get(0).get("prediction")).isIn(10, 20); - } - - @Test - void multipleColumnsEachGetPrediction() { - Map contextRow = new HashMap<>(); - contextRow.put("ID", "id-1"); - contextRow.put("genre_ID", 10); - contextRow.put("currency_code", "EUR"); - - Map predictRow = new HashMap<>(); - predictRow.put("ID", "id-2"); - predictRow.put("genre_ID", "[PREDICT]"); - predictRow.put("currency_code", "[PREDICT]"); - - List> result = - cut.fetchPredictions( - List.of(contextRow, predictRow), List.of("genre_ID", "currency_code"), null); - - assertThat(result).hasSize(1); - Map prediction = result.get(0); - assertThat(prediction).containsKey("genre_ID"); - assertThat(prediction).containsKey("currency_code"); - } - - @Test - void noContextValuesResultsInNullPrediction() { - // Only the predict row, no context rows with real values - Map predictRow = new HashMap<>(); - predictRow.put("ID", "id-1"); - predictRow.put("genre_ID", "[PREDICT]"); - - List> result = - cut.fetchPredictions(List.of(predictRow), List.of("genre_ID"), null); - - assertThat(result).hasSize(1); - @SuppressWarnings("unchecked") - List> preds = (List>) result.get(0).get("genre_ID"); - assertThat(preds.get(0).get("prediction")).isNull(); - } - - @Test - void multipleRowsWithPredictEachGetPredicted() { - Map contextRow = new HashMap<>(); - contextRow.put("ID", "id-1"); - contextRow.put("genre_ID", 10); - - Map predictRow1 = new HashMap<>(); - predictRow1.put("ID", "id-2"); - predictRow1.put("genre_ID", "[PREDICT]"); - - Map predictRow2 = new HashMap<>(); - predictRow2.put("ID", "id-3"); - predictRow2.put("genre_ID", "[PREDICT]"); - - List> result = - cut.fetchPredictions( - List.of(contextRow, predictRow1, predictRow2), List.of("genre_ID"), null); - - assertThat(result).hasSize(2); - } -} diff --git a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java b/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java deleted file mode 100644 index 5d40fd7..0000000 --- a/srv/src/test/java/com/sap/cds/feature/ai/client/setup/AICoreSetupHandlerTest.java +++ /dev/null @@ -1,216 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. - */ -package com.sap.cds.feature.ai.client.setup; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assumptions.assumeTrue; -import static org.mockito.Mockito.*; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.ai.client.AICoreClient; -import com.sap.cds.services.environment.CdsEnvironment; -import com.sap.cds.services.mt.SubscribeEventContext; -import com.sap.cds.services.mt.UnsubscribeEventContext; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Integration test verifying the full AICoreSetup lifecycle against a real AI Core instance. - * Requires an AI Core service instance bound to the app (VCAP_SERVICES). - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class AICoreSetupHandlerTest { - - private static final String TEST_TENANT = "it-test-tenant-" + System.currentTimeMillis(); - private static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; - private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final Logger logger = LoggerFactory.getLogger(AICoreSetupHandlerTest.class); - - private AICoreSetupHandler cut; - private AICoreClient client; - private Map credentials; - private String aiApiUrl; - - @BeforeAll - void setup() throws Exception { - String vcap = System.getenv("VCAP_SERVICES"); - String serviceKey = null; - if (vcap != null) { - Map vcapServices = MAPPER.readValue(vcap, new TypeReference<>() {}); - List> aicoreBindings = - MAPPER.convertValue(vcapServices.get("aicore"), new TypeReference<>() {}); - if (aicoreBindings != null && !aicoreBindings.isEmpty()) { - serviceKey = MAPPER.writeValueAsString(aicoreBindings.get(0).get("credentials")); - } - } - assumeTrue( - serviceKey != null, "Skipping integration test: VCAP_SERVICES with aicore binding not set"); - - credentials = MAPPER.readValue(serviceKey, new TypeReference<>() {}); - aiApiUrl = ((Map) credentials.get("serviceurls")).get("AI_API_URL").toString(); - - System.setProperty("cds.multitenancy.enabled", "true"); - CdsEnvironment environment = mock(CdsEnvironment.class); - cut = new AICoreSetupHandler(environment); - client = new AICoreClient(cut); - } - - @AfterAll - void tearDown() { - System.clearProperty("cds.multitenancy.enabled"); - } - - /** - * Full lifecycle: 1. Subscribe → resource group created online + in local cache 2. - * fetchPredictions → RPT-1 deployment created online + in local cache 3. Unsubscribe → resource - * group deleted online + removed from both caches - */ - @Test - void lifecycle_subscribeCreatesPredictCreatesDeploymentUnsubscribeDeletesAll() throws Exception { - - // ── 1. Subscribe ────────────────────────────────────────────────────── - SubscribeEventContext subCtx = mock(SubscribeEventContext.class); - when(subCtx.getTenant()).thenReturn(TEST_TENANT); - cut.afterSubscribe(subCtx); - - String token = fetchToken(); - String resourceGroupId = findResourceGroupByTenant(token, TEST_TENANT); - assertThat(resourceGroupId) - .as("Resource group should exist online after subscribe") - .isNotNull(); - assertThat(cut.getTenantResourceGroupCache()) - .as("Resource group should be cached after subscribe") - .containsKey(TEST_TENANT); - - // ── 2. fetchPredictions ─────────────────────────────────────────────── - // This triggers RPT-1 deployment creation + polling until RUNNING. - // The inference call itself may fail (due to no meaningful data) but the deployment - // must be RUNNING before inference is attempted. - try { - client.fetchPredictions( - List.of(Map.of("ID", "1", "title", "test book")), List.of("genre"), TEST_TENANT); - } catch (RuntimeException e) { - // Inference errors are acceptable; we only assert the deployment was created. - } - - token = fetchToken(); - String deploymentId = findDeploymentForResourceGroup(token, resourceGroupId); - assertThat(deploymentId) - .as("RPT-1 deployment should exist online after fetchPredictions") - .isNotNull(); - assertThat(cut.getResourceGroupDeploymentCache()) - .as("Deployment should be cached after fetchPredictions") - .containsEntry(resourceGroupId, deploymentId); - - // ── 3. Unsubscribe ──────────────────────────────────────────────────── - UnsubscribeEventContext unsubCtx = mock(UnsubscribeEventContext.class); - when(unsubCtx.getTenant()).thenReturn(TEST_TENANT); - cut.beforeUnsubscribe(unsubCtx); - - // Caches are cleared synchronously - assertThat(cut.getTenantResourceGroupCache()) - .as("Resource group should be removed from cache after unsubscribe") - .doesNotContainKey(TEST_TENANT); - assertThat(cut.getResourceGroupDeploymentCache()) - .as("Deployment should be removed from cache after unsubscribe") - .doesNotContainKey(resourceGroupId); - - // AI Core deletes resource groups asynchronously — poll until gone - waitUntilResourceGroupGone(TEST_TENANT); - } - - // ── helpers ────────────────────────────────────────────────────────────── - - private void waitUntilResourceGroupGone(String tenant) throws Exception { - long delay = 500; - int maxRetries = 10; - for (int i = 0; i < maxRetries; i++) { - Thread.sleep(delay); - String id = findResourceGroupByTenant(fetchToken(), tenant); - if (id == null) return; - logger.debug( - "Resource group still present after unsubscribe, retrying in {} ms ({}/{})", - delay * 2, - i + 1, - maxRetries); - delay *= 2; - } - assertThat(findResourceGroupByTenant(fetchToken(), tenant)) - .as("Resource group should be gone online after unsubscribe") - .isNull(); - } - - private String findResourceGroupByTenant(String token, String tenant) throws Exception { - String encoded = URLEncoder.encode(TENANT_LABEL_KEY + "=" + tenant, "UTF-8"); - HttpResponse response = - HttpClient.newHttpClient() - .send( - HttpRequest.newBuilder() - .uri(URI.create(aiApiUrl + "/v2/admin/resourceGroups?labelSelector=" + encoded)) - .header("Authorization", "Bearer " + token) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - Map result = MAPPER.readValue(response.body(), new TypeReference<>() {}); - List> resources = - MAPPER.convertValue(result.get("resources"), new TypeReference<>() {}); - if (resources == null || resources.isEmpty()) return null; - return resources.get(0).get("resourceGroupId").toString(); - } - - private String findDeploymentForResourceGroup(String token, String resourceGroupId) - throws Exception { - HttpResponse response = - HttpClient.newHttpClient() - .send( - HttpRequest.newBuilder() - .uri(URI.create(aiApiUrl + "/v2/lm/deployments")) - .header("Authorization", "Bearer " + token) - .header("AI-Resource-Group", resourceGroupId) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - Map result = MAPPER.readValue(response.body(), new TypeReference<>() {}); - List> resources = - MAPPER.convertValue(result.get("resources"), new TypeReference<>() {}); - if (resources == null || resources.isEmpty()) return null; - return resources.stream() - .filter(d -> "RUNNING".equals(d.get("status")) || "PENDING".equals(d.get("status"))) - .findFirst() - .map(d -> d.get("id").toString()) - .orElse(null); - } - - private String fetchToken() throws Exception { - String body = - "grant_type=client_credentials" - + "&client_id=" - + URLEncoder.encode(credentials.get("clientid").toString(), "UTF-8") - + "&client_secret=" - + URLEncoder.encode(credentials.get("clientsecret").toString(), "UTF-8"); - HttpResponse response = - HttpClient.newHttpClient() - .send( - HttpRequest.newBuilder() - .uri(URI.create(credentials.get("url") + "/oauth/token")) - .header("Content-Type", "application/x-www-form-urlencoded") - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(), - HttpResponse.BodyHandlers.ofString()); - Map data = MAPPER.readValue(response.body(), new TypeReference<>() {}); - return data.get("access_token").toString(); - } -} From b48f76ab820de001e474c6d3d271d9425023d07b Mon Sep 17 00:00:00 2001 From: "hyperspace-insights[bot]" <209611008+hyperspace-insights[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 11:55:59 +0200 Subject: [PATCH 18/42] =?UTF-8?q?chore(hyperspace):=20=F0=9F=A4=96=20Add?= =?UTF-8?q?=20PR=20Bot=20Configuration=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: hyperspace-insights[bot] <209611008+hyperspace-insights[bot]@users.noreply.github.com> --- .hyperspace/pull_request_bot.json | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .hyperspace/pull_request_bot.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": [] +} From 15c1569caaa5bc97eb5184ef79c616f2c361aa73 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Fri, 22 May 2026 16:10:18 +0200 Subject: [PATCH 19/42] chore: bootstrap ci/cd workflows (#19) * chore: bootstrap ci/cd workflows * chore: address pr bot review findings * fix: specify ai-core service instance and key * rename blackduck identifier --- .github/CODEOWNERS | 7 + .github/actions/build/action.yml | 33 +++++ .github/actions/cf-bind/action.yml | 78 +++++++++++ .github/actions/deploy-release/action.yml | 71 ++++++++++ .github/actions/integration-tests/action.yml | 34 +++++ .../actions/scan-with-blackduck/action.yml | 44 +++++-- .github/actions/scan-with-codeql/action.yml | 23 ++-- .github/actions/scan-with-sonar/action.yml | 80 ++++++++++++ .github/dependabot.yml | 30 +++++ .github/workflows/issue.yml | 31 +++++ .github/workflows/main.yml | 32 ++--- .github/workflows/pipeline.yml | 123 ++++++++++++++++++ .github/workflows/pr.yml | 18 +++ .github/workflows/prevent-issue-labeling.yml | 25 ++++ .github/workflows/release.yml | 123 ++++++++++++++++++ .github/workflows/stale.yml | 25 ++++ 16 files changed, 732 insertions(+), 45 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/actions/build/action.yml create mode 100644 .github/actions/cf-bind/action.yml create mode 100644 .github/actions/deploy-release/action.yml create mode 100644 .github/actions/integration-tests/action.yml create mode 100644 .github/actions/scan-with-sonar/action.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/issue.yml create mode 100644 .github/workflows/pipeline.yml create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/prevent-issue-labeling.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/stale.yml 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/build/action.yml b/.github/actions/build/action.yml new file mode 100644 index 0000000..6544c1c --- /dev/null +++ b/.github/actions/build/action.yml @@ -0,0 +1,33 @@ +name: Maven Build +description: Builds a Maven project. + +inputs: + java-version: + description: The Java version the build will run with. + required: true + maven-version: + description: The Maven version the build will 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: Set up Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Install @sap/cds-dk + run: npm i -g @sap/cds-dk@9.9.1 + shell: bash + + - name: Maven Build + run: mvn clean install -DskipTests -B -ntp -Dcds.install-node.skip -P skip-integration-tests + shell: bash diff --git a/.github/actions/cf-bind/action.yml b/.github/actions/cf-bind/action.yml new file mode 100644 index 0000000..e88154e --- /dev/null +++ b/.github/actions/cf-bind/action.yml @@ -0,0 +1,78 @@ +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: Install CF CLI + shell: bash + env: + CF_CLI_VERSION: '8.18.3' + run: | + wget -q "https://packages.cloudfoundry.org/stable?release=linux64-binary&version=${CF_CLI_VERSION}&source=github-rel" -O cf-cli.tar.gz + tar -xzf cf-cli.tar.gz + sudo mv cf8 /usr/local/bin/cf + cf --version + + - name: CF Login + shell: bash + env: + CF_USERNAME: ${{ inputs.cf-username }} + CF_PASSWORD: ${{ inputs.cf-password }} + CF_API: ${{ inputs.cf-api }} + CF_ORG: ${{ inputs.cf-org }} + CF_SPACE: ${{ inputs.cf-space }} + run: | + for i in {1..5}; do + cf api "$CF_API" && \ + cf auth && \ + cf target -o "$CF_ORG" -s "$CF_SPACE" && break + if [ "$i" -eq 5 ]; then + echo "cf login failed after 5 attempts." + exit 1 + fi + echo "cf login failed, retrying ($i/5)..." + sleep 10 + done + + - 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/deploy-release/action.yml b/.github/actions/deploy-release/action.yml new file mode 100644 index 0000000..2c70176 --- /dev/null +++ b/.github/actions/deploy-release/action.yml @@ -0,0 +1,71 @@ +name: Deploy Release to Maven Central +description: Deploys released artifacts to Maven Central repository. + +inputs: + user: + description: The user used for the upload (technical user for maven central upload) + required: true + password: + description: The password used for the upload (technical user for maven central upload) + required: true + pgp-pub-key: + description: The public pgp key ID + required: true + pgp-private-key: + description: The private pgp key + required: true + pgp-passphrase: + description: The passphrase for pgp + required: true + revision: + description: The revision of cds-feature-ai + required: true + maven-version: + description: The Maven version the build will run with. + required: true + +runs: + using: composite + steps: + - name: Set up Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: sapmachine + java-version: '17' + cache: maven + server-id: central + server-username: MAVEN_CENTRAL_USER + server-password: MAVEN_CENTRAL_PASSWORD + + - name: Set up Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Install @sap/cds-dk + run: npm i -g @sap/cds-dk@9.9.1 + shell: bash + + - name: Import GPG Key + run: | + echo "$PGP_PRIVATE_KEY" | gpg --batch --passphrase "$PASSPHRASE" --import + shell: bash + env: + PGP_PRIVATE_KEY: ${{ inputs.pgp-private-key }} + PASSPHRASE: ${{ inputs.pgp-passphrase }} + + - name: Deploy to Maven Central + run: > + mvn -B -ntp --show-version + -Dmaven.install.skip=true + -Dmaven.test.skip=true + -Dcds.install-node.skip + -Dgpg.passphrase="$GPG_PASSPHRASE" + -Dgpg.keyname="$GPG_PUB_KEY" + clean deploy -P deploy-release,skip-integration-tests + shell: bash + env: + MAVEN_CENTRAL_USER: ${{ inputs.user }} + MAVEN_CENTRAL_PASSWORD: ${{ inputs.password }} + GPG_PASSPHRASE: ${{ inputs.pgp-passphrase }} + GPG_PUB_KEY: ${{ inputs.pgp-pub-key }} diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml new file mode 100644 index 0000000..bae0ec6 --- /dev/null +++ b/.github/actions/integration-tests/action.yml @@ -0,0 +1,34 @@ +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 -Dcds.install-node.skip + shell: bash + + - name: Integration Tests (spring + mtx-local) + run: cds bind --exec -- mvn clean verify -ntp -B -f pom.xml -P mtx-integration-tests -Dcds.install-node.skip + working-directory: integration-tests + shell: bash diff --git a/.github/actions/scan-with-blackduck/action.yml b/.github/actions/scan-with-blackduck/action.yml index 8b23ade..f8e3292 100644 --- a/.github/actions/scan-with-blackduck/action.yml +++ b/.github/actions/scan-with-blackduck/action.yml @@ -10,14 +10,18 @@ inputs: required: true java-version: description: The version of Java to use - default: '17' + default: "17" required: false maven-version: description: The Maven version the build shall run with. required: true + version: + description: The project version to report to Black Duck (e.g. release tag). If empty, falls back to the Maven `revision` reduced to major-minor. + required: false + default: "" scan_mode: - description: The scan mode to use (FULL or RAPID) - default: 'RAPID' + description: The scan mode to use (FULL uploads a report to the Black Duck server; RAPID is a fast policy gate without server upload). + default: "FULL" required: false runs: @@ -35,10 +39,19 @@ runs: with: maven-version: ${{ inputs.maven-version }} - - name: Get Revision - id: get-revision + - name: Resolve Project Version + id: resolve-version + env: + VERSION_INPUT: ${{ inputs.version }} run: | - echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT + if [ -n "$VERSION_INPUT" ]; then + VERSION="$VERSION_INPUT" + else + REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout) + VERSION=$(echo "$REVISION" | cut -d. -f1,2) + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved BlackDuck project version: $VERSION" shell: bash - name: BlackDuck Security Scan @@ -49,15 +62,18 @@ runs: blackducksca_scan_full: ${{ inputs.scan_mode == 'FULL' }} github_token: ${{ inputs.github_token }} detect_args: > - --detect.project.name=com.sap.cds.feature.ai - --detect.project.version.name=${{ steps.get-revision.outputs.REVISION }} + --detect.project.name=com.sap.cds.cds-ai + --detect.project.version.name=${{ steps.resolve-version.outputs.VERSION }} + --detect.project.user.groups=CDSJAVA-OPEN-SOURCE --detect.included.detector.types=MAVEN - --detect.excluded.directories=**/node_modules,**/*test*,**/localrepo,**/target/site,**/*-site.jar,**/samples/** + --detect.excluded.directories=**/*test*,**/samples/** + --detect.maven.included.modules=cds-feature-ai-core,cds-feature-recommendations,cds-starter-ai + --detect.maven.excluded.scopes=test,provided --detect.tools=DETECTOR,BINARY_SCAN + --detect.timeout=6000 --detect.risk.report.pdf=false + --detect.policy.check.fail.on.severities=NONE + --detect.force.success.on.skip=true + --blackduck.signature.scanner.memory=4096 + --blackduck.trust.cert=true --logging.level.detect=INFO - env: - BLACKDUCKSCA_TOKEN: ${{ inputs.blackduck_token }} - BLACKDUCKSCA_URL: https://sap.blackducksoftware.com/ - BLACKDUCK_API_TOKEN: ${{ inputs.blackduck_token }} - DETECT_MAVEN_BUILD_COMMAND: '-pl com.sap.cds:cds-feature-ai' diff --git a/.github/actions/scan-with-codeql/action.yml b/.github/actions/scan-with-codeql/action.yml index bdaca32..5cbd337 100644 --- a/.github/actions/scan-with-codeql/action.yml +++ b/.github/actions/scan-with-codeql/action.yml @@ -1,5 +1,5 @@ -name: Scan with CodeQL -description: Scans the project with CodeQL +name: CodeQL Analysis +description: Runs CodeQL security analysis on the project. inputs: java-version: @@ -8,11 +8,15 @@ inputs: maven-version: description: The Maven version to use for the build. required: true + language: + description: The CodeQL language to analyze (java-kotlin or actions). + required: true runs: using: composite steps: - name: Set up Java ${{ inputs.java-version }} + if: inputs.language == 'java-kotlin' uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: java-version: ${{ inputs.java-version }} @@ -20,6 +24,7 @@ runs: cache: maven - name: Set up Maven ${{ inputs.maven-version }} + if: inputs.language == 'java-kotlin' uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 with: maven-version: ${{ inputs.maven-version }} @@ -27,23 +32,21 @@ runs: - name: Initialize CodeQL uses: github/codeql-action/init@ed410739ba306e4ebe5e123421a6bd694e494a2b # v4 with: - languages: java-kotlin - build-mode: manual + languages: ${{ inputs.language }} + build-mode: ${{ inputs.language == 'java-kotlin' && 'manual' || 'none' }} queries: security-extended - name: Install @sap/cds-dk - run: npm i -g @sap/cds-dk - shell: bash - - - name: Install npm dependencies - run: npm install + if: inputs.language == 'java-kotlin' + run: npm i -g @sap/cds-dk@9.9.1 shell: bash - name: Build Java code + if: inputs.language == 'java-kotlin' run: mvn clean compile -B -ntp -Dcds.install-node.skip shell: bash - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@ed410739ba306e4ebe5e123421a6bd694e494a2b # v4 with: - category: "/language:java-kotlin" + category: "/language:${{ inputs.language }}" diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml new file mode 100644 index 0000000..e658949 --- /dev/null +++ b/.github/actions/scan-with-sonar/action.yml @@ -0,0 +1,80 @@ +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 project for SonarQube scan + run: cds bind --exec -- mvn clean verify -ntp -B -P mtx-integration-tests -Dcds.install-node.skip + working-directory: integration-tests + shell: bash + + - name: Verify JaCoCo reports exist + run: | + echo "=== Checking JaCoCo reports ===" + find . -name "jacoco.xml" -type f + for module in cds-feature-ai-core cds-feature-recommendations cds-starter-ai; do + if [ -f "$module/target/site/jacoco/jacoco.xml" ]; then + echo "Found: $module/target/site/jacoco/jacoco.xml" + else + echo "Missing: $module/target/site/jacoco/jacoco.xml" + fi + done + 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=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 }}/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/workflows/issue.yml b/.github/workflows/issue.yml new file mode 100644 index 0000000..6aa729f --- /dev/null +++ b/.github/workflows/issue.yml @@ -0,0 +1,31 @@ +name: Label issues + +permissions: {} + +on: + issues: + types: + - opened + +jobs: + label_issues: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - run: gh issue edit "$NUMBER" --add-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: New + + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `👋 Hello @${context.payload.issue.user.login}, thank you for submitting this issue. Our team is reviewing your report and will follow up with you as soon as possible.` + }) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 63b8bbf..738ce1b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,11 +1,5 @@ name: CI - MAIN -permissions: - contents: read - actions: read - security-events: write - packages: read - env: MAVEN_VERSION: '3.9.15' @@ -19,6 +13,8 @@ jobs: name: Blackduck Scan runs-on: ubuntu-latest timeout-minutes: 30 + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -29,19 +25,13 @@ jobs: blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} maven-version: ${{ env.MAVEN_VERSION }} - scan_mode: RAPID - - codeql: - name: CodeQL Scan - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + scan_mode: FULL - - name: Scan With CodeQL - continue-on-error: true - uses: ./.github/actions/scan-with-codeql - with: - maven-version: ${{ env.MAVEN_VERSION }} - java-version: '17' + 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..fbc1873 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,123 @@ +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: Install @sap/cds-dk + run: npm i -g @sap/cds-dk@9.9.1 + shell: bash + + - name: Run Tests + run: mvn test -ntp -B -P skip-integration-tests -Dcds.install-node.skip + + 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 }} + + 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: ./.github/actions/scan-with-codeql + with: + java-version: '17' + maven-version: ${{ env.MAVEN_VERSION }} + language: ${{ matrix.language }} 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..6c35030 --- /dev/null +++ b/.github/workflows/prevent-issue-labeling.yml @@ -0,0 +1,25 @@ +name: Prevent "New" Label on Issues + +permissions: {} + +on: + issues: + types: [labeled] + +jobs: + remove_new_label: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Remove "New" label if applied by non-bot user + if: > + contains(github.event.issue.labels.*.name, 'New') && + github.event.label.name == 'New' && + github.event.sender.login != 'github-actions[bot]' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + gh issue edit "$ISSUE_NUMBER" --remove-label "New" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2c468e9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,123 @@ +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: ./.github/actions/scan-with-blackduck + with: + blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + maven-version: ${{ env.MAVEN_VERSION }} + 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: ./.github/actions/build + with: + java-version: ${{ env.JAVA_VERSION }} + maven-version: ${{ env.MAVEN_VERSION }} + + 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: ./.github/actions/deploy-release + with: + user: ${{ secrets.CENTRAL_REPOSITORY_USER }} + password: ${{ secrets.CENTRAL_REPOSITORY_PASS }} + pgp-pub-key: ${{ secrets.PGP_PUBKEY_ID }} + pgp-private-key: ${{ secrets.PGP_PRIVATE_KEY }} + pgp-passphrase: ${{ secrets.PGP_PASSPHRASE }} + revision: ${{ github.event.release.tag_name }} + maven-version: ${{ env.MAVEN_VERSION }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..4feaa66 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,25 @@ +name: "Close stale issues" + +permissions: {} + +on: + schedule: + - cron: "30 1 * * *" + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + actions: write + issues: write + pull-requests: write + steps: + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 + with: + close-issue-message: "This issue has been automatically closed due to 2 weeks of inactivity. If you believe this was a mistake, please reopen or comment to continue the discussion." + days-before-stale: -1 + days-before-issue-close: 14 + days-before-pr-close: -1 + stale-issue-label: "author action" + remove-issue-stale-when-updated: true + labels-to-remove-when-unstale: "author action" From be2c2b3e87c794323cbda1c1afbc937cf4f7b337 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Fri, 22 May 2026 19:55:03 +0200 Subject: [PATCH 20/42] fix: run npm ci before cds build in integration-tests (#17) (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: run npm ci before cds build in integration-tests (#17) Without `npm ci`, `cds build` ran in `integration-tests/spring/` without the `@cap-js/ai` Node plugin, so the generated CSN was missing the synthetic `SAP_Recommendations` association and 8 RecommendationTest cases failed on clean clone with `JsonPath $['SAP_Recommendations']: PathNotFoundException`. Add `cds.install-node` and `cds.npm-ci` executions to the cds-maven-plugin block in `integration-tests/spring/pom.xml`, mirroring the pattern already used in `samples/bookshop/srv/pom.xml`. CI continues to skip the Node download via `-Dcds.install-node.skip`; clean local clones get a self-contained Node + `npm ci` into `integration-tests/node_modules/`. Commit `package-lock.json` for `integration-tests/` and `samples/bookshop/` so `npm ci` is reproducible, and remove the `**/package-lock.json` entry from `.gitignore`. Closes #17 * fix: regenerate lockfiles against public npm registry The @cap-js/ai entry in the previous lockfiles pointed at int.repositories.cloud.sap (SAP-internal Artifactory), which is unreachable from GitHub-hosted runners and broke `npm ci` in CI with EAI_AGAIN. Regenerated both lockfiles with the public registry forced for the @cap-js scope so they resolve from registry.npmjs.org. * fix: invert integration-test modules profile and suppress AICore kinds models Two fixes wrapped together since they both unblock CI on PR #21. 1. Maven `` is additive in profiles, so `skip-integration-tests` was a no-op — it re-listed the source modules instead of removing the integration ones. Invert the structure: default modules are source- only; a `with-integration-tests` profile (active by default) adds `integration-tests` and `coverage-report`. CI consumers switch from `-P skip-integration-tests` to `-P '!with-integration-tests'`. 2. In CI, `cds bind --exec` writes a `[hybrid]` profile entry to `.cdsrc-private.json` for the `ai-core` binding, kind `AICore-btp`. `cds build` then expands the kind's `model: "@cap-js/ai/srv/AICoreService"` into the build's model list, which collides with the `AICore` service shipped by `cds-feature-ai-core` (loaded via `using { AICore } from 'com.sap.cds/ai'`). The existing `requires.AICore.model = false` override only suppresses the named-require model, not the kind's. Add `requires.kinds.AICore-btp.model = false` (and the same for `AICore-mocked`) so the kind never contributes a model to cds build. * fix: build root modules before sonar scan; dispatch event handlers on registered service type - SonarQube scan job ran `mvn clean verify` from `integration-tests/` without first installing the source modules, so the integration-tests build couldn't resolve `cds-feature-ai-core` etc. as JAR dependencies. Mirror the integration-tests action: install the three source modules with `-pl ... -am -DskipTests` first, then run the scan build. - `AICoreServiceConfiguration.eventHandlers` recomputed `hasAICoreBinding` separately from `services()` and unconditionally cast the registered service to `AICoreServiceImpl`. If the two calls disagreed (or if another configurer registered a Mock first), the cast threw `ClassCastException: MockAICoreServiceImpl cannot be cast to AICoreServiceImpl`. Read the actually-registered service from the catalog and dispatch via `instanceof` patterns so the handler set always matches the live registration. --- .github/actions/build/action.yml | 2 +- .github/actions/deploy-release/action.yml | 2 +- .github/actions/scan-with-sonar/action.yml | 4 + .github/workflows/pipeline.yml | 2 +- .gitignore | 1 - .../core/AICoreServiceConfiguration.java | 18 +- integration-tests/.cdsrc.json | 8 + integration-tests/package-lock.json | 2795 ++++++++++++++++ integration-tests/spring/pom.xml | 16 + pom.xml | 13 +- samples/bookshop/package-lock.json | 2806 +++++++++++++++++ 11 files changed, 5642 insertions(+), 25 deletions(-) create mode 100644 integration-tests/package-lock.json create mode 100644 samples/bookshop/package-lock.json diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 6544c1c..b62d932 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -29,5 +29,5 @@ runs: shell: bash - name: Maven Build - run: mvn clean install -DskipTests -B -ntp -Dcds.install-node.skip -P skip-integration-tests + run: mvn clean install -DskipTests -B -ntp -Dcds.install-node.skip -P '!with-integration-tests' shell: bash diff --git a/.github/actions/deploy-release/action.yml b/.github/actions/deploy-release/action.yml index 2c70176..5f38a83 100644 --- a/.github/actions/deploy-release/action.yml +++ b/.github/actions/deploy-release/action.yml @@ -62,7 +62,7 @@ runs: -Dcds.install-node.skip -Dgpg.passphrase="$GPG_PASSPHRASE" -Dgpg.keyname="$GPG_PUB_KEY" - clean deploy -P deploy-release,skip-integration-tests + clean deploy -P deploy-release,'!with-integration-tests' shell: bash env: MAVEN_CENTRAL_USER: ${{ inputs.user }} diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index e658949..755189c 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -37,6 +37,10 @@ runs: echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> "$GITHUB_OUTPUT" shell: bash + - 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 -Dcds.install-node.skip + shell: bash + - name: Build project for SonarQube scan run: cds bind --exec -- mvn clean verify -ntp -B -P mtx-integration-tests -Dcds.install-node.skip working-directory: integration-tests diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index fbc1873..750ac7f 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -38,7 +38,7 @@ jobs: shell: bash - name: Run Tests - run: mvn test -ntp -B -P skip-integration-tests -Dcds.install-node.skip + run: mvn test -ntp -B -P '!with-integration-tests' -Dcds.install-node.skip integration-tests: name: Integration Tests (Java ${{ matrix.java-version }}) diff --git a/.gitignore b/.gitignore index 1126c78..6abc628 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ default-env.json **/node/ **/node_modules/ -**/package-lock.json **/.mta/ *.mtar 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 index 358d3d9..ff07e12 100644 --- 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 @@ -63,15 +63,10 @@ public void services(CdsRuntimeConfigurer configurer) { public void eventHandlers(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); - boolean hasBinding = hasAICoreBinding(runtime); - - if (hasBinding) { - AICoreServiceImpl service = - (AICoreServiceImpl) - runtime - .getServiceCatalog() - .getService(AICoreService.class, AICoreService.DEFAULT_NAME); + 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)); @@ -83,12 +78,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler(new AICoreSetupHandler(service)); logger.debug("Registered AI-Core Setup Handler for MTX subscribe/unsubscribe."); } - } else { - MockAICoreServiceImpl mockService = - (MockAICoreServiceImpl) - runtime - .getServiceCatalog() - .getService(AICoreService.class, AICoreService.DEFAULT_NAME); + } else if (registered instanceof MockAICoreServiceImpl mockService) { configurer.eventHandler(new MockEntityHandler()); configurer.eventHandler(new AICoreApplicationServiceHandler(mockService)); logger.debug("Registered Mock AI-Core Implementation"); diff --git a/integration-tests/.cdsrc.json b/integration-tests/.cdsrc.json index dff0396..a939726 100644 --- a/integration-tests/.cdsrc.json +++ b/integration-tests/.cdsrc.json @@ -8,6 +8,14 @@ "requires": { "AICore": { "model": false + }, + "kinds": { + "AICore-btp": { + "model": false + }, + "AICore-mocked": { + "model": false + } } } } 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/spring/pom.xml b/integration-tests/spring/pom.xml index 65f4260..e864036 100644 --- a/integration-tests/spring/pom.xml +++ b/integration-tests/spring/pom.xml @@ -69,6 +69,22 @@ com.sap.cds cds-maven-plugin + + cds.install-node + + install-node + + + + cds.npm-ci + + npm + + + ${project.basedir}/.. + ci + + cds.resolve diff --git a/pom.xml b/pom.xml index da3fa6e..2579442 100644 --- a/pom.xml +++ b/pom.xml @@ -40,8 +40,6 @@ cds-feature-ai-core cds-feature-recommendations cds-starter-ai - integration-tests - coverage-report @@ -420,12 +418,13 @@ - skip-integration-tests + with-integration-tests + + true + - cds-feature-ai-core - cds-feature-recommendations - cds-starter-ai - + integration-tests + coverage-report 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" + } + } + } +} From 11e8f967d0ead85f37e5ba8a1ae660cdc40f68ac Mon Sep 17 00:00:00 2001 From: Marvin L Date: Fri, 22 May 2026 20:26:07 +0200 Subject: [PATCH 21/42] fix: filter recommendations to unfilled fields only (#22) (#23) The Fiori recommendation handler used to emit a SAP_Recommendations entry for every column in the prediction response, regardless of whether the user had already filled that column. This worked locally because the mock client only returns predictions for cells marked [PREDICT], but it broke in CI where the real RPT model returns predictions for every target column. Filter the response to columns that were null in the input row so behaviour is consistent across both clients. Adds a regression unit test that simulates the RPT-style response shape. --- .../FioriRecommendationHandler.java | 4 +- .../FioriRecommendationHandlerTest.java | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) 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 index f6ae25c..46aef51 100644 --- 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 @@ -215,8 +215,10 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + List missingPredictionElementNames = + predictionElementNames.stream().filter(c -> row.get(c) == null).toList(); Map recommendations = - buildRecommendations(db, predictions.get(0), predictionElementNames, context, rowType); + buildRecommendations(db, predictions.get(0), missingPredictionElementNames, context, rowType); row.put("SAP_Recommendations", recommendations); } 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 index cdb4158..7e46d4b 100644 --- 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 @@ -271,6 +271,31 @@ void composedKeys_usesSyntheticKeyColumn() { }); } + @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); + }); + } + // ── helpers ──────────────────────────────────────────────────────────────── private CdsReadEventContext readContext(String entityName, List> resultRows) { @@ -317,6 +342,31 @@ private static Result twoContextRows() { .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) -> { From 8df01f4fabf6548c0a8f62881d68508b7a72c4ec Mon Sep 17 00:00:00 2001 From: Marvin L Date: Fri, 22 May 2026 21:45:15 +0200 Subject: [PATCH 22/42] fix(itest): repair resource-group cleanup so quota does not leak (#24) (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(itest): disable ResourceGroup mutating tests pending admin scopes (#24) The 4 CREATE/DELETE tests in ResourceGroupTest fail in CI with HTTP 403 because /v2/admin/resourceGroups requires AI Core tenant-admin scopes (see SAP AI Core service guide §3.2 / §6.3), which the bound CI service key (sap-internal plan, technical user cap.calesi.munich@sap.com) does not carry. Disable them with @Disabled until the IAM situation is sorted; read-only readAll_returnsResourceGroups stays enabled. Refs #24 * fix(itest): repair resource-group cleanup so quota does not leak (#24) The test extension's pre-cleanup never ran in CI because it gated on AICORE_SERVICE_KEY, but the bound credentials are exposed via VCAP_SERVICES. 48 itest-rg-* groups had leaked into the cdsmunich tenant, hitting the 50-group hard quota and turning every CREATE into a 400/quota error. - Remove the env-var guard from ResourceGroupCleanupExtension so the ResourceGroupApi auto-discovers credentials from VCAP_SERVICES (same pattern as production AICoreServiceImpl). - Add cleanup-resource-groups.cjs and invoke it with if: always() in the integration-tests action, mirroring cap-js/ai. Survives JVM crashes. - Re-enable the four ResourceGroupTest CREATE/DELETE tests. * chore(itest): drop unused .cjs cleanup script The JUnit ResourceGroupCleanupExtension already handles cleanup exhaustively in @BeforeAll/@AfterAll — verified green in the previous CI run. The .cjs script failed with 'No impl configured for cds.requires.kinds.AICore-btp' anyway and was wrapped in `|| true`, so it provided no real safety net. --- .../feature/aicore/itest/ResourceGroupCleanupExtension.java | 6 ------ 1 file changed, 6 deletions(-) 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 index 88f3328..1f90ec3 100644 --- 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 @@ -36,12 +36,6 @@ private void cleanupOnce(ExtensionContext context, String storeKey) { } private void deleteTestResourceGroups() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - if (envKey == null || envKey.isBlank()) { - logger.debug("No AI Core binding available, skipping resource group cleanup."); - return; - } - try { ResourceGroupApi api = new ResourceGroupApi(); BckndResourceGroupList list = api.getAll(null, null, null, null, null, null, null); From 5f8a3fd6c29419587a9bad3434d31a412efbb9d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 14:28:41 +0200 Subject: [PATCH 23/42] Bump the minor-patch group across 1 directory with 5 updates (#20) Bumps the minor-patch group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [com.github.ben-manes.caffeine:caffeine](https://github.com/ben-manes/caffeine) | `3.2.0` | `3.2.4` | | [org.apache.maven.plugins:maven-enforcer-plugin](https://github.com/apache/maven-enforcer) | `3.6.2` | `3.6.3` | | [com.diffplug.spotless:spotless-maven-plugin](https://github.com/diffplug/spotless) | `3.4.0` | `3.5.0` | | [com.sap.ai.sdk:core](https://github.com/SAP/ai-sdk-java) | `1.18.0` | `1.19.0` | | [com.sap.ai.sdk.foundationmodels:sap-rpt](https://github.com/SAP/ai-sdk-java) | `1.18.0` | `1.19.0` | Updates `com.github.ben-manes.caffeine:caffeine` from 3.2.0 to 3.2.4 - [Release notes](https://github.com/ben-manes/caffeine/releases) - [Commits](https://github.com/ben-manes/caffeine/compare/v3.2.0...v3.2.4) Updates `org.apache.maven.plugins:maven-enforcer-plugin` from 3.6.2 to 3.6.3 - [Release notes](https://github.com/apache/maven-enforcer/releases) - [Commits](https://github.com/apache/maven-enforcer/compare/enforcer-3.6.2...enforcer-3.6.3) Updates `com.diffplug.spotless:spotless-maven-plugin` from 3.4.0 to 3.5.0 - [Release notes](https://github.com/diffplug/spotless/releases) - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/maven/3.4.0...maven/3.5.0) Updates `com.sap.ai.sdk:core` from 1.18.0 to 1.19.0 - [Release notes](https://github.com/SAP/ai-sdk-java/releases) - [Changelog](https://github.com/SAP/ai-sdk-java/blob/main/docs/release_notes.md) - [Commits](https://github.com/SAP/ai-sdk-java/compare/rel/1.18.0...rel/1.19.0) Updates `com.sap.ai.sdk.foundationmodels:sap-rpt` from 1.18.0 to 1.19.0 - [Release notes](https://github.com/SAP/ai-sdk-java/releases) - [Changelog](https://github.com/SAP/ai-sdk-java/blob/main/docs/release_notes.md) - [Commits](https://github.com/SAP/ai-sdk-java/compare/rel/1.18.0...rel/1.19.0) Updates `com.sap.ai.sdk.foundationmodels:sap-rpt` from 1.18.0 to 1.19.0 - [Release notes](https://github.com/SAP/ai-sdk-java/releases) - [Changelog](https://github.com/SAP/ai-sdk-java/blob/main/docs/release_notes.md) - [Commits](https://github.com/SAP/ai-sdk-java/compare/rel/1.18.0...rel/1.19.0) --- updated-dependencies: - dependency-name: com.github.ben-manes.caffeine:caffeine dependency-version: 3.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-patch - dependency-name: org.apache.maven.plugins:maven-enforcer-plugin dependency-version: 3.6.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-patch - dependency-name: com.diffplug.spotless:spotless-maven-plugin dependency-version: 3.5.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-patch - dependency-name: com.sap.ai.sdk:core dependency-version: 1.19.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-patch - dependency-name: com.sap.ai.sdk.foundationmodels:sap-rpt dependency-version: 1.19.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-patch - dependency-name: com.sap.ai.sdk.foundationmodels:sap-rpt dependency-version: 1.19.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 2579442..1a4408b 100644 --- a/pom.xml +++ b/pom.xml @@ -65,7 +65,7 @@ 4.9.0 - 1.18.0 + 1.19.0 3.4.5 @@ -91,13 +91,13 @@ 3.28.0 - 3.6.2 + 3.6.3 1.7.3 5.30.0 - 3.2.0 + 3.2.4 4.9.8.3 @@ -386,7 +386,7 @@ com.diffplug.spotless spotless-maven-plugin - 3.4.0 + 3.5.0 From 99999e02f79e593daec00d75aed86dd8e621c3d3 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Wed, 27 May 2026 10:14:20 +0200 Subject: [PATCH 24/42] fix(mtx): wire MTX subscribe lifecycle for mock AI Core service (#26) (#27) * fix(mtx): wire MTX subscribe lifecycle for mock AI Core service (#26) The local-with-tenants profile didn't set cds.requires.AICore.multiTenancy, so AICoreServiceImpl.isMultiTenancyEnabled() returned false and seven MTX integration tests failed. Even with the flag, the mock service had no SubscribeEventContext handler, so the tenant resource-group cache was never populated and the cache-based assertions still failed. - Enable cds.requires.AICore.multiTenancy under the local-with-tenants profile so MT registers in mtx-local integration tests. - Add MockAICoreSetupHandler mirroring AICoreSetupHandler's subscribe/ unsubscribe lifecycle in-memory (no AI Core API calls). - Register the mock setup handler from AICoreServiceConfiguration when the mock service runs with MT enabled. - Use AICoreService interface in mtx-local integration tests; drop the AICoreServiceImpl casts and the assumeTrue skips that those casts forced. * ci: split mtx-local into its own job without AI Core binding (#26) Running spring + mtx-local under one cds-bind made mtx-local inherit the AI Core binding, so AICoreServiceConfiguration registered the real AICoreServiceImpl and AICoreSetupHandler hit the live API on every subscribe/unsubscribe (HTTP 409 on resource-group delete in CI). The mtx-local module is intended to run in-memory against the mock service. - Add a Local MTX Tests job (matrix Java 17/21, no cf-bind) that runs mvn -pl integration-tests/mtx-local/srv -am -P mtx-integration-tests. - Drop the mtx-integration-tests profile from the integration-tests composite action so its cds-bind invocation runs only the spring reactor (the default). * change default rg and add provisioning to itest * fix(itest): avoid 404 on keyed RG read by listing then filtering A keyed READ on AICore.resourceGroups routes through ResourceGroupHandler.onRead -> resourceGroupApi.get(rgId), which returns 404 for a not-yet-existing RG instead of an empty list. Use a non-keyed Select and filter client-side so the helper can detect absence and INSERT the RG. * fix(itest): increase RPT deployment poll budget to ~6 min Prior 10-attempt cap (~145 s sleep budget) was right at the edge of the observed RPT-1 deployment time (~150 s) and timed out in CI. Bumping to 18 attempts gives a ~6 min ceiling so cold deployments have headroom without making genuinely failed runs hang indefinitely. * fix(itest): tune RPT poll budget to 15 attempts (~4.7 min) 18 attempts gave too much headroom; 15 is enough margin over the observed ~150 s RPT-1 deployment time without making genuinely stuck runs hang for too long. * fix(itest): scope cleanup to label-owned RGs to protect other CF instances AI Core resource groups are shared across all CF service instances bound to the same AI Core tenant, so listing every RG and stopping its deployments before delete was destroying work owned by other instances. Adopt cap-js/ai's pattern (srv/ai-core/resourceGroups.js) of stamping each RG with an owner label at create time and filtering by it on cleanup, but use a distinct key (CDS_FEATURE_AI_ITEST_OWNER) so the itest cleanup never collides with prod tenant cleanup. Cleanup also short-circuits when the owner is unset or equal to the local-dev RG name, so dev runs reuse their RG across invocations. While here, drop the test scaffold's hand-rolled config/deployment helpers and call the existing AICoreServiceImpl.deploymentId(...) which already does lookup-or-create-with-polling. The RG insert + provisioned poll stay in test scope per design. * fix(itest): drop redundant null-check on rg.getLabels() BckndResourceGroup.getLabels() is @Nonnull, so the null guard was flagged by SpotBugs (RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE). * fix(ci): resolve integration test and SonarQube pipeline failures - ResourceGroupCleanupExtension: defer afterAll cleanup to end of test suite using CloseableResource pattern, preventing premature deletion of the resource group before all test classes have completed - TenantIsolationTest: catch Throwable (not just Exception) in tearDown so MockMvc AssertionError does not escape the safety catch blocks - SonarQube scan: remove -P mtx-integration-tests from the verify step; mtx-local tests already run in their dedicated local-mtx-tests job and including them under cds bind causes real AI Core API calls that conflict with the mock-based test design - BaseIntegrationTest: expose clearDeploymentIdCache() so the cleanup extension can invalidate stale cached deployment IDs after deletion * fix(itest): prevent beforeAll cleanup from deleting active resource group The ResourceGroupCleanupExtension.beforeAll() was deleting the resource group that AICoreServiceTest (which runs first) had just created, causing ActionTest and all Recommendation tests to fail with 400/500 from AI Core. - Remove beforeAll/cleanupOnce from ResourceGroupCleanupExtension; the deferred afterAll (CloseableResource at suite end) is sufficient - Expose ensureResourceGroupProvisioned() as protected in BaseIntegrationTest - Add @BeforeAll to ActionTest to guarantee the resource group is provisioned before deploymentId tests run, regardless of class order * fix(ci): generate JaCoCo XML reports for SonarQube scan - Add jacoco report goal to cds-feature-ai-core and integration-tests/spring pom.xml (they only had prepare-agent, no report generation) - Move mtx-local dependency in coverage-report to mtx-integration-tests profile so the aggregate report can be built without mtx-local - Update scan-with-sonar action: remove -DskipTests so unit tests run and generate coverage; add install to integration tests step; add step to build coverage-report aggregate; point sonar at the aggregate report * fix(itest): prevent ResourceGroupTest from leaking resource groups ResourceGroupTest creates itest-rg-* groups during tests but AfterEach cleanup silently failed (409 Conflict) because the RG wasn't provisioned yet. Additionally, the ResourceGroupCleanupExtension only cleaned label-owned groups, missing the itest-rg-* prefix groups entirely. - ResourceGroupTest: wait for RG to be provisioned before delete in AfterEach, matching the pattern used in delete_resourceGroup test - ResourceGroupCleanupExtension: add prefix-based safety-net cleanup that always deletes any itest-rg-* groups at suite end, regardless of whether CDS_AICORE_TEST_RESOURCE_GROUP is set - scan-with-sonar: set CDS_AICORE_TEST_RESOURCE_GROUP env var so the cleanup extension actually runs in the SonarQube scan job * fix(itest): stop_deployment test must use own RG, not 'default' The test was hardcoded to query and stop the first RUNNING deployment in the 'default' resource group. Since AI Core is scoped at the subaccount level, this stopped deployments from other CF spaces sharing the same subaccount (e.g. orchestration deployments). Use getDefaultResourceGroup() which resolves to the CI-specific itest-* resource group, ensuring only test-owned deployments are affected. * fix(itest): remove all hardcoded 'default' resource group references DeploymentTest.update_targetStatus_stopsRunningDeployment was still hardcoded to query and stop deployments in the 'default' resource group, which stopped the user's orchestration deployment in the shared subaccount. Also fix ConfigurationTest to avoid creating orphan configs in 'default'. All test classes now use getDefaultResourceGroup() which resolves to the CI-specific itest-* resource group via CDS_AICORE_TEST_RESOURCE_GROUP. * fix: retry on HTTP 404 from AI Core inference endpoint AI Core deployments report RUNNING status before the inference gateway has fully propagated the route. This causes 404 responses for a short window after deployment creation. The retry logic already handled 403 and 412 as transient 'not ready yet' states but was missing 404. Adding 404 to the retryable status codes makes inference calls resilient to this AI Core eventual-consistency window. * fix: cap exponential backoff at 30s and increase itest retry budget The exponential backoff with no cap resulted in extremely long final intervals (77s, 154s) leaving only ~153s of total retry window across 10 attempts. For fresh deployments where inference needs up to 5 min to become stable, this was insufficient. - Cap max interval at 30s so retries stay frequent - Increase integration test maxRetries to 15 (gives ~5.5 min window) - Together with the 404 retry fix, this handles AI Core's deployment warm-up period reliably * fix(ci): move RG cleanup to dedicated job after all itests complete When Java 17 and Java 21 integration tests run in parallel, they create separate deployments of the same RPT model. When the first-finishing job cleaned up (stopped its deployment + deleted its RG), AI Core tore down shared model serving infrastructure causing the still-running job's inference calls to fail with 404. - ResourceGroupCleanupExtension: afterAll now only cleans itest-rg-* leaked groups (safety net), no longer stops/deletes the main test RG - pipeline.yml: add dedicated 'integration-tests-cleanup' job that runs with if:always() after BOTH integration test jobs complete, then deletes all resource groups for the current run * fix(ci): remove dead code flagged by PMD PMD caught unused deleteOwnedResourceGroups, deleteLabeledResourceGroups, stopDeploymentsInResourceGroup, waitForDeploymentsStopped, and ownsResourceGroup methods left over from moving RG cleanup to CI job. Also remove unused clearDeploymentIdCache(). * fix(itest): disable stop_deployment tests that kill shared RPT deployment ActionTest.stop_deployment_changesTargetStatus and DeploymentTest. update_targetStatus_stopsRunningDeployment stop the RPT deployment created by AICoreServiceTest. Subsequent Recommendation tests then hit 404 from the inference endpoint for the full retry budget (~250s) because the stopped deployment's inference route is gone and the newly created replacement deployment needs warmup time. Disable both tests until they are refactored to create and stop their own isolated deployment instead of the shared one. * fix(ci): cleanup job reads credentials from VCAP_SERVICES not AICORE_SERVICE_KEY cds bind --exec injects credentials via VCAP_SERVICES, not AICORE_SERVICE_KEY. The cleanup script was trying to parse AICORE_SERVICE_KEY which was undefined, causing a JSON parse error. --- .github/actions/integration-tests/action.yml | 6 +- .github/actions/scan-with-sonar/action.yml | 21 ++--- .github/workflows/pipeline.yml | 94 +++++++++++++++++++ cds-feature-ai-core/pom.xml | 7 ++ .../core/AICoreServiceConfiguration.java | 4 + .../aicore/core/AICoreServiceImpl.java | 7 +- .../aicore/core/MockAICoreSetupHandler.java | 41 ++++++++ .../aicore/core/AICoreServiceImplTest.java | 8 ++ coverage-report/pom.xml | 18 +++- .../srv/src/main/resources/application.yaml | 3 + .../aicore/itest/mt/MtxLifecycleTest.java | 20 +--- .../itest/mt/SubscribeUnsubscribeTest.java | 17 ++-- .../aicore/itest/mt/TenantIsolationTest.java | 25 +++-- integration-tests/spring/pom.xml | 7 ++ .../src/main/resources/application.yaml | 4 + .../aicore/itest/AICoreServiceTest.java | 8 ++ .../cds/feature/aicore/itest/ActionTest.java | 18 +++- .../aicore/itest/BaseIntegrationTest.java | 93 +++++++++++++----- .../aicore/itest/ConfigurationTest.java | 18 ++-- .../feature/aicore/itest/DeploymentTest.java | 20 ++-- .../itest/ResourceGroupCleanupExtension.java | 45 ++++----- .../aicore/itest/ResourceGroupTest.java | 9 +- .../NonStandardKeyRecommendationTest.java | 2 + .../itest/RecommendationTest.java | 2 + 24 files changed, 364 insertions(+), 133 deletions(-) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index bae0ec6..fd01b3d 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -28,7 +28,9 @@ runs: run: mvn clean install -ntp -B -pl cds-feature-ai-core,cds-feature-recommendations,cds-starter-ai -am -DskipTests -Dcds.install-node.skip shell: bash - - name: Integration Tests (spring + mtx-local) - run: cds bind --exec -- mvn clean verify -ntp -B -f pom.xml -P mtx-integration-tests -Dcds.install-node.skip + - 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 -Dcds.install-node.skip working-directory: integration-tests shell: bash diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index 755189c..0fd0943 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -37,26 +37,25 @@ runs: echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> "$GITHUB_OUTPUT" shell: bash - - 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 -Dcds.install-node.skip + - name: Build and test main modules + run: mvn clean install -ntp -B -pl cds-feature-ai-core,cds-feature-recommendations,cds-starter-ai -am -Dcds.install-node.skip shell: bash - - name: Build project for SonarQube scan - run: cds bind --exec -- mvn clean verify -ntp -B -P mtx-integration-tests -Dcds.install-node.skip + - 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 -Dcds.install-node.skip working-directory: integration-tests shell: bash + - name: Generate aggregate coverage report + run: mvn verify -ntp -B -pl coverage-report -Dcds.install-node.skip + shell: bash + - name: Verify JaCoCo reports exist run: | echo "=== Checking JaCoCo reports ===" find . -name "jacoco.xml" -type f - for module in cds-feature-ai-core cds-feature-recommendations cds-starter-ai; do - if [ -f "$module/target/site/jacoco/jacoco.xml" ]; then - echo "Found: $module/target/site/jacoco/jacoco.xml" - else - echo "Missing: $module/target/site/jacoco/jacoco.xml" - fi - done if [ -f "coverage-report/target/site/jacoco-aggregate/jacoco.xml" ]; then echo "Found: coverage-report/target/site/jacoco-aggregate/jacoco.xml" else diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 750ac7f..c13335f 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -69,6 +69,100 @@ jobs: java-version: ${{ matrix.java-version }} maven-version: ${{ env.MAVEN_VERSION }} + integration-tests-cleanup: + name: Cleanup Integration Test Resources + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [integration-tests] + if: always() + permissions: + contents: read + 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: Cleanup resource groups + working-directory: integration-tests + shell: bash + run: | + cds bind --exec -- node -e " + const https = require('https'); + const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}'); + const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null'); + if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); } + const tokenUrl = key.url + '/oauth/token'; + const apiUrl = key.serviceurls.AI_API_URL; + function fetch(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',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data})); + }); + req.on('error',reject); + if(opts.body) req.write(opts.body); + req.end(); + }); + } + (async () => { + const tokenParams = new URLSearchParams({grant_type:'client_credentials'}); + const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64'); + const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); + const token = JSON.parse(tokenRes.body).access_token; + const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; + const prefix = 'itest-${{ github.run_id }}-${{ github.run_attempt }}'; + const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); + const groups = JSON.parse(rgRes.body).resources || []; + const toDelete = groups.filter(rg => rg.resourceGroupId && rg.resourceGroupId.startsWith(prefix)); + for (const rg of toDelete) { + const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); + console.log('Delete', rg.resourceGroupId, '->', res.status); + } + console.log('Cleaned up', toDelete.length, 'resource groups for run ${{ github.run_id }}'); + })().catch(e => { console.error(e.message); process.exit(1); }); + " + + 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: Install @sap/cds-dk + run: npm i -g @sap/cds-dk@9.9.1 + shell: bash + + - name: Run Local MTX Tests + run: mvn clean verify -ntp -B -pl integration-tests/mtx-local/srv -am -P mtx-integration-tests -Dcds.install-node.skip + sonarqube-scan: name: SonarQube Scan runs-on: ubuntu-latest diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index b80bac3..7658c88 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -61,6 +61,13 @@ prepare-agent + + jacoco-site-report + + report + + verify + 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 index ff07e12..2d6c372 100644 --- 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 @@ -81,6 +81,10 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } 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 index 88e7b3a..2c0d4b6 100644 --- 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 @@ -360,7 +360,7 @@ static boolean notReadyYet(OpenApiRequestException e) { while (t != null) { if (t instanceof OpenApiRequestException oae) { Integer code = oae.statusCode(); - if (code != null && (code == 403 || code == 412)) { + if (code != null && (code == 403 || code == 404 || code == 412)) { return true; } } @@ -369,11 +369,14 @@ static boolean notReadyYet(OpenApiRequestException e) { 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)) + .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/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java new file mode 100644 index 0000000..4d5730c --- /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-feature-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/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 index 7a1053c..0593c29 100644 --- 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 @@ -33,6 +33,14 @@ void notReadyYet_topLevel412_returnsTrue() { 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); diff --git a/coverage-report/pom.xml b/coverage-report/pom.xml index 9e07824..e32cb96 100644 --- a/coverage-report/pom.xml +++ b/coverage-report/pom.xml @@ -32,13 +32,21 @@ cds-feature-ai-integration-tests-spring test - - com.sap.cds - cds-feature-ai-integration-tests-mtx-local - test - + + + mtx-integration-tests + + + com.sap.cds + cds-feature-ai-integration-tests-mtx-local + test + + + + + diff --git a/integration-tests/mtx-local/srv/src/main/resources/application.yaml b/integration-tests/mtx-local/srv/src/main/resources/application.yaml index 2bf2b72..df8c3e4 100644 --- a/integration-tests/mtx-local/srv/src/main/resources/application.yaml +++ b/integration-tests/mtx-local/srv/src/main/resources/application.yaml @@ -16,6 +16,9 @@ cds: spring: config.activate.on-profile: local-with-tenants cds: + requires: + AICore: + multiTenancy: true security: mock: tenants: 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 index 6e66e65..cc5ee9c 100644 --- 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 @@ -5,11 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.core.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -50,7 +48,7 @@ void tearDown() { @Test void unsubscribe_isIdempotent() throws Exception { - AICoreServiceImpl service = getService(); + AICoreService service = getService(); subscriptionEndpointClient.subscribeTenant(TENANT); subscriptionEndpointClient.unsubscribeTenant(TENANT); @@ -62,7 +60,7 @@ void unsubscribe_isIdempotent() throws Exception { @Test void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { - AICoreServiceImpl service = getService(); + AICoreService service = getService(); for (int i = 0; i < 2; i++) { subscriptionEndpointClient.subscribeTenant(TENANT); @@ -73,17 +71,7 @@ void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { } } - /** - * The {@code AICoreSetupHandler} is only registered when a real AI Core binding is present (see - * {@code AICoreServiceConfiguration#eventHandlers}); without one, the runtime falls back to the - * mock service and these lifecycle assertions don't apply. - */ - private AICoreServiceImpl getService() { - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - assumeTrue( - service instanceof AICoreServiceImpl, - "Skipping: no AI Core binding available, MockAICoreServiceImpl in use."); - return (AICoreServiceImpl) service; + private AICoreService getService() { + return 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 index 01ab896..f35f3b9 100644 --- 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 @@ -10,7 +10,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.core.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -51,11 +50,9 @@ void subscribeTenant_thenServiceIsReachable() throws Exception { @Test void subscribeTenant_createsResourceGroup() throws Exception { - subscriptionEndpointClient.subscribeTenant("tenant-3"); + AICoreService service = getService(); - AICoreServiceImpl service = - (AICoreServiceImpl) - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + subscriptionEndpointClient.subscribeTenant("tenant-3"); assertThat(service.isMultiTenancyEnabled()).isTrue(); assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); @@ -63,11 +60,9 @@ void subscribeTenant_createsResourceGroup() throws Exception { @Test void unsubscribeTenant_clearsCaches() throws Exception { - subscriptionEndpointClient.subscribeTenant("tenant-3"); + AICoreService service = getService(); - AICoreServiceImpl service = - (AICoreServiceImpl) - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + subscriptionEndpointClient.subscribeTenant("tenant-3"); assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); @@ -98,4 +93,8 @@ void tearDown() { } catch (Exception ignored) { } } + + private AICoreService getService() { + return 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 index 79121a7..fb42ad5 100644 --- 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 @@ -7,7 +7,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.core.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -37,17 +36,17 @@ void setup() { @Test void multiTenancyEnabled() { - AICoreServiceImpl service = getService(); + AICoreService service = getService(); assertThat(service.isMultiTenancyEnabled()).isTrue(); } @Test void differentTenants_getDifferentResourceGroups() throws Exception { + AICoreService service = getService(); + subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); - AICoreServiceImpl service = getService(); - String rg1 = service.getTenantResourceGroupCache().get("tenant-1"); String rg2 = service.getTenantResourceGroupCache().get("tenant-2"); @@ -58,9 +57,9 @@ void differentTenants_getDifferentResourceGroups() throws Exception { @Test void resourceGroupPrefix_applied() throws Exception { - subscriptionEndpointClient.subscribeTenant("tenant-1"); + AICoreService service = getService(); - AICoreServiceImpl service = getService(); + subscriptionEndpointClient.subscribeTenant("tenant-1"); String rg = service.getTenantResourceGroupCache().get("tenant-1"); assertThat(rg).startsWith(service.getResourceGroupPrefix()); @@ -68,10 +67,11 @@ void resourceGroupPrefix_applied() throws Exception { @Test void clearTenantCache_onlyAffectsTarget() throws Exception { + AICoreService service = getService(); + subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); - AICoreServiceImpl service = getService(); String rg2 = service.getTenantResourceGroupCache().get("tenant-2"); service.clearTenantCache("tenant-1"); @@ -80,24 +80,23 @@ void clearTenantCache_onlyAffectsTarget() throws Exception { assertThat(service.getTenantResourceGroupCache()).containsEntry("tenant-2", rg2); } - private AICoreServiceImpl getService() { - return (AICoreServiceImpl) - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private AICoreService getService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } @AfterEach void tearDown() { try { subscriptionEndpointClient.unsubscribeTenant("tenant-1"); - } catch (Exception ignored) { + } catch (Throwable ignored) { } try { subscriptionEndpointClient.unsubscribeTenant("tenant-2"); - } catch (Exception ignored) { + } catch (Throwable ignored) { } try { subscriptionEndpointClient.unsubscribeTenant("tenant-3"); - } catch (Exception ignored) { + } catch (Throwable ignored) { } } } diff --git a/integration-tests/spring/pom.xml b/integration-tests/spring/pom.xml index e864036..1843703 100644 --- a/integration-tests/spring/pom.xml +++ b/integration-tests/spring/pom.xml @@ -137,6 +137,13 @@ prepare-agent + + jacoco-site-report + + report + + verify + diff --git a/integration-tests/spring/src/main/resources/application.yaml b/integration-tests/spring/src/main/resources/application.yaml index f97684c..cefb50a 100644 --- a/integration-tests/spring/src/main/resources/application.yaml +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -7,6 +7,10 @@ spring: mode: always cds: + requires: + AICore: + resourceGroup: ${CDS_AICORE_TEST_RESOURCE_GROUP:cap-java-ai-default} + maxRetries: 15 security: mock: users: 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 index daebbf5..2087db0 100644 --- 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 @@ -7,10 +7,18 @@ import com.sap.cds.feature.aicore.core.AICoreService; import com.sap.cds.feature.recommendation.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(); 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 index e7e230d..b098f7f 100644 --- 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 @@ -15,13 +15,20 @@ 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 { - private static final String TEST_RG = "default"; + @BeforeAll + void ensureResourceGroupReady() { + ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreService().getDefaultResourceGroup()); + } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { @@ -64,14 +71,17 @@ void deploymentId_cachedOnSecondCall() { 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 = getAICoreService().getDefaultResourceGroup(); Result deployments = service.run( Select.from("AICore.deployments") - .where(d -> d.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); String deploymentId = null; for (Row row : deployments) { @@ -88,7 +98,7 @@ void stop_deployment_changesTargetStatus() { service.run( Update.entity("AICore.deployments") .where(d -> d.get("id").eq(targetId)) - .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", TEST_RG))); + .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); Result readResult = service.run( @@ -97,7 +107,7 @@ void stop_deployment_changesTargetStatus() { d -> d.get("id") .eq(targetId) - .and(d.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + .and(d.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); assertThat(readResult.list()).hasSize(1); Row row = readResult.single(); 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 index 210f17a..031d035 100644 --- 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 @@ -6,12 +6,17 @@ import com.sap.cds.Result; import com.sap.cds.Row; import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.recommendation.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; @@ -21,6 +26,13 @@ @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; @@ -33,36 +45,65 @@ protected CqnService getAICoreCqnService() { return (CqnService) getAICoreService(); } - protected String getOrCreateRptConfig(CqnService service, String resourceGroup) { - Result configs = - service.run( - Select.from("AICore.configurations") - .where( - c -> - c.get("scenarioId") - .eq("foundation-models") - .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); + protected String ensureRptDeploymentReady() { + String resourceGroup = getAICoreService().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); + } - for (Row row : configs) { - if ("sap-rpt-1-small".equals(row.get("name"))) { - return (String) row.get("id"); + 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; + } - Result created = - service.run( - Insert.into("AICore.configurations") - .entry( - Map.of( - "name", "sap-rpt-1-small", - "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"))))); + 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"); + } - return (String) created.single().get("id"); + 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 index 8ec1c23..de158ac 100644 --- 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 @@ -16,15 +16,14 @@ class ConfigurationTest extends BaseIntegrationTest { - private static final String TEST_RG = "default"; - @Test void readAll_returnsConfigurations() { CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreService().getDefaultResourceGroup(); Result result = service.run( Select.from("AICore.configurations") - .where(c -> c.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + .where(c -> c.get("resourceGroup_resourceGroupId").eq(resourceGroup))); assertThat(result.list()).isNotNull(); } @@ -32,6 +31,7 @@ void readAll_returnsConfigurations() { @Test void readAll_filterByScenario() { CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreService().getDefaultResourceGroup(); Result result = service.run( Select.from("AICore.configurations") @@ -39,7 +39,7 @@ void readAll_filterByScenario() { c -> c.get("scenarioId") .eq("foundation-models") - .and(c.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); assertThat(result.list()).isNotNull(); } @@ -47,6 +47,7 @@ void readAll_filterByScenario() { @Test void create_andReadById() { CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreService().getDefaultResourceGroup(); String configName = "itest-config-" + System.currentTimeMillis(); Result created = @@ -61,7 +62,7 @@ void create_andReadById() { "scenarioId", "foundation-models", "resourceGroup_resourceGroupId", - TEST_RG, + resourceGroup, "parameterBindings", List.of( Map.of("key", "modelName", "value", "sap-rpt-1-small"), @@ -79,7 +80,7 @@ void create_andReadById() { c -> c.get("id") .eq(configId) - .and(c.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); assertThat(readResult.list()).hasSize(1); Row row = readResult.single(); @@ -91,6 +92,7 @@ void create_andReadById() { @Test void create_withParameterBindings_mapsCorrectly() { CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreService().getDefaultResourceGroup(); String configName = "itest-params-" + System.currentTimeMillis(); Result created = @@ -105,7 +107,7 @@ void create_withParameterBindings_mapsCorrectly() { "scenarioId", "foundation-models", "resourceGroup_resourceGroupId", - TEST_RG, + resourceGroup, "parameterBindings", List.of( Map.of("key", "param1", "value", "value1"), @@ -120,7 +122,7 @@ void create_withParameterBindings_mapsCorrectly() { c -> c.get("id") .eq(configId) - .and(c.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + .and(c.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); Row row = readResult.single(); @SuppressWarnings("unchecked") 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 index 2ce3d6b..227c4a5 100644 --- 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 @@ -12,19 +12,19 @@ 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 { - private static final String TEST_RG = "default"; - @Test void readAll_returnsDeployments() { CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreService().getDefaultResourceGroup(); Result result = service.run( Select.from("AICore.deployments") - .where(d -> d.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); assertThat(result.list()).isNotNull(); } @@ -32,10 +32,11 @@ void readAll_returnsDeployments() { @Test void readSingle_returnsDeploymentDetails() { CqnService service = getAICoreCqnService(); + String resourceGroup = getAICoreService().getDefaultResourceGroup(); Result all = service.run( Select.from("AICore.deployments") - .where(d -> d.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); assumeFalse(all.list().isEmpty(), "No deployments available"); @@ -47,7 +48,7 @@ void readSingle_returnsDeploymentDetails() { d -> d.get("id") .eq(id) - .and(d.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + .and(d.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); assertThat(single.list()).hasSize(1); Row row = single.single(); @@ -56,14 +57,17 @@ void readSingle_returnsDeploymentDetails() { 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 = getAICoreService().getDefaultResourceGroup(); Result deployments = service.run( Select.from("AICore.deployments") - .where(d -> d.get("resourceGroup_resourceGroupId").eq(TEST_RG))); + .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); String deploymentId = null; for (Row row : deployments) { @@ -80,7 +84,7 @@ void update_targetStatus_stopsRunningDeployment() { service.run( Update.entity("AICore.deployments") .where(d -> d.get("id").eq(targetId)) - .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", TEST_RG))); + .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); Result readResult = service.run( @@ -89,7 +93,7 @@ void update_targetStatus_stopsRunningDeployment() { d -> d.get("id") .eq(targetId) - .and(d.get("resourceGroup_resourceGroupId").eq(TEST_RG)))); + .and(d.get("resourceGroup_resourceGroupId").eq(resourceGroup)))); assertThat(readResult.list()).hasSize(1); Row row = readResult.single(); 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 index 1f90ec3..dc251d1 100644 --- 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 @@ -7,52 +7,47 @@ 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.BeforeAllCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class ResourceGroupCleanupExtension implements BeforeAllCallback, AfterAllCallback { +/** + * 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); - @Override - public void beforeAll(ExtensionContext context) { - cleanupOnce(context, "resourceGroupCleanupBeforeDone"); - } + private static final String TEST_RG_PREFIX = "itest-rg-"; @Override public void afterAll(ExtensionContext context) { - cleanupOnce(context, "resourceGroupCleanupAfterDone"); - } - - private void cleanupOnce(ExtensionContext context, String storeKey) { ExtensionContext.Store store = context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL); - if (store.get(storeKey) != null) { - return; - } - store.put(storeKey, true); - deleteTestResourceGroups(); + store.getOrComputeIfAbsent( + "resourceGroupCleanupShutdownHook", + k -> (ExtensionContext.Store.CloseableResource) this::deleteResourceGroupsByPrefix); } - private void deleteTestResourceGroups() { + private void deleteResourceGroupsByPrefix() { try { - ResourceGroupApi api = new ResourceGroupApi(); - BckndResourceGroupList list = api.getAll(null, null, null, null, null, null, null); - - for (BckndResourceGroup rg : list.getResources()) { + 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 && !"default".equals(id)) { + if (id != null && id.startsWith(TEST_RG_PREFIX)) { try { - api.delete(id); - logger.info("Cleaned up integration test resource group: {}", id); + rgApi.delete(id); + logger.info("Cleaned up leaked test resource group: {}", id); } catch (Exception e) { - logger.warn("Failed to delete resource group {}: {}", id, e.getMessage()); + logger.warn("Failed to delete test resource group {}: {}", id, e.getMessage()); } } } } catch (Exception e) { - logger.warn("Resource group cleanup failed: {}", e.getMessage()); + 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 index 26b153c..00d334c 100644 --- 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 @@ -29,10 +29,11 @@ class ResourceGroupTest extends BaseIntegrationTest { void cleanup() { if (createdResourceGroupId != null) { try { - getAICoreCqnService() - .run( - Delete.from("AICore.resourceGroups") - .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); + CqnService service = getAICoreCqnService(); + waitForResourceGroupProvisioned(service, createdResourceGroupId); + service.run( + Delete.from("AICore.resourceGroups") + .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); } catch (Exception ignored) { } createdResourceGroupId = null; 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 index 5e95f48..270b699 100644 --- 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 @@ -34,6 +34,8 @@ class NonStandardKeyRecommendationTest extends BaseIntegrationTest { @BeforeAll void setupContextData() { + ensureRptDeploymentReady(); + PersistenceService db = runtime .getServiceCatalog() 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 index b9cd108..eff26e4 100644 --- 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 @@ -36,6 +36,8 @@ class RecommendationTest extends BaseIntegrationTest { @BeforeAll void setupContextData() { + ensureRptDeploymentReady(); + PersistenceService db = runtime .getServiceCatalog() From 5ed1f09dcd999bf7c022647de1ec5e739ed27be6 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Wed, 27 May 2026 12:55:59 +0200 Subject: [PATCH 25/42] fix: use idiomatic patterns for error messages and stream collection (#40) - Replace string concatenation in ServiceException messages with SLF4J-style {} placeholders in AICoreSetupHandler (3 locations) - Replace Collectors.toList() with .toList() in AbstractCrudHandler - Replace new ArrayList<>() with List.of() for null case - Remove unused imports (ArrayList, Collectors) Closes #32 --- .../cds/feature/aicore/core/AICoreSetupHandler.java | 10 +++++++--- .../aicore/core/handler/AbstractCrudHandler.java | 6 ++---- 2 files changed, 9 insertions(+), 7 deletions(-) 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 index e605e15..fddd340 100644 --- 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 @@ -41,7 +41,8 @@ public void afterSubscribe(SubscribeEventContext context) { } catch (Exception e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to create AI Core resources for tenant: " + tenantId, + "Failed to create AI Core resources for tenant: {}", + tenantId, e); } } @@ -79,7 +80,9 @@ private void deleteResourceGroupForTenant(String tenantId) { } throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to delete AI Core resource group " + resourceGroupId + " for tenant " + tenantId, + "Failed to delete AI Core resource group {} for tenant {}", + resourceGroupId, + tenantId, e); } } @@ -103,7 +106,8 @@ private String resolveResourceGroupId(String tenantId) { } catch (OpenApiRequestException e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to look up AI Core resource group for tenant " + tenantId, + "Failed to look up AI Core resource group for tenant {}", + tenantId, e); } List resources = result.getResources(); 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 index 3793e76..47f3858 100644 --- 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 @@ -5,12 +5,10 @@ import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.services.handler.EventHandler; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; -import java.util.stream.Collectors; abstract class AbstractCrudHandler implements EventHandler { @@ -34,7 +32,7 @@ protected static Map merge(Map keys, Map List mapResources(List resources, Function mapper) { - if (resources == null) return new ArrayList<>(); - return resources.stream().map(mapper).collect(Collectors.toList()); + if (resources == null) return List.of(); + return resources.stream().map(mapper).toList(); } } From 45a18eb5922cee28adc07e518c82aad30b115678 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Wed, 27 May 2026 14:34:50 +0200 Subject: [PATCH 26/42] refactor: introduce AICoreElements constants; refactor DeploymentHandler with record (#41) * fix: use idiomatic patterns for error messages and stream collection - Replace string concatenation in ServiceException messages with SLF4J-style {} placeholders in AICoreSetupHandler (3 locations) - Replace Collectors.toList() with .toList() in AbstractCrudHandler - Replace new ArrayList<>() with List.of() for null case - Remove unused imports (ArrayList, Collectors) Closes #32 * refactor: use cds-maven-plugin generated interfaces for type-safe entity access Replace manual AICoreElements constants class with generated typed interfaces from cds-maven-plugin (following cds-feature-attachments pattern): - Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml - Generated interfaces: Deployments, Configurations, ResourceGroups, BckndResourceGroupLabel (with constants + typed getters/setters) - Update all handlers to use generated constants (e.g., Deployments.ID) and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id)) - Remove manual AICoreElements.java (replaced by code generation) - DeploymentHandler: use Deployments.create() with typed setters instead of 17-parameter buildDeploymentData method Closes #33 * fix: use explicit types and remove unnecessary cast in ConfigurationHandler - Use List instead of var for bindings - Use ParameterArgumentBinding instead of var for bm - Remove unnecessary (CdsData) cast since ParameterArgumentBinding extends CdsData * fix: use ArtifactArgumentBinding generated interface for inputArtifactBindings Replace raw CdsData with typed ArtifactArgumentBinding.create() and setters for the inputArtifactBindings mapping in ConfigurationHandler. * refactor: use typed data parameters in handler method signatures Use CAP Java's handler data parameter injection for onCreate/onUpdate: - DeploymentHandler.onCreate(ctx, List) - DeploymentHandler.onUpdate(ctx, List) - ConfigurationHandler.onCreate(ctx, List) - ResourceGroupHandler.onCreate(ctx, List) This eliminates manual CQN entry extraction (context.getCqn().entries()) and enables typed getters (entry.getName() vs entry.get("name")). Update tests to pass data as the second parameter directly. * fix: remove unused CdsData import in ConfigurationHandler --- cds-feature-ai-core/pom.xml | 37 ++++ .../aicore/core/handler/ActionHandler.java | 4 +- .../core/handler/ConfigurationHandler.java | 81 ++++---- .../core/handler/DeploymentHandler.java | 181 +++++++----------- .../core/handler/ResourceGroupHandler.java | 58 +++--- .../core/handler/DeploymentHandlerTest.java | 13 +- .../handler/ResourceGroupHandlerTest.java | 23 +-- 7 files changed, 189 insertions(+), 208 deletions(-) diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index 7658c88..45109b8 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -51,6 +51,43 @@ ${project.artifactId} + + com.sap.cds + cds-maven-plugin + + + cds.install-node + + install-node + + + + 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 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 index b811c96..611cd23 100644 --- 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 @@ -3,6 +3,8 @@ */ package com.sap.cds.feature.aicore.core.handler; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; + import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; @@ -40,7 +42,7 @@ public void onResourceGroupForTenant(EventContext context) { @On(event = "stop", entity = AICoreService.DEPLOYMENTS) public void onStop(EventContext context) { Map keys = asMap(context.get("keys")); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(keys); DeploymentApi api = service.getDeploymentApi(); 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 index d3fb8d9..9fd641f 100644 --- 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 @@ -8,12 +8,15 @@ 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.CdsData; import com.sap.cds.feature.aicore.core.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.CqnInsert; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.cds.CdsCreateEventContext; @@ -22,6 +25,7 @@ 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; @@ -54,98 +58,91 @@ public void onRead(CdsReadEventContext context) { keys, values); - String id = (String) keys.get("id"); + String id = (String) keys.get(Configurations.ID); if (id != null) { AiConfiguration config = configurationApi.get(resourceGroupId, id); - context.setResult(List.of(toMap(config, resourceGroupId))); + context.setResult(List.of(toConfigurations(config, resourceGroupId))); } else { - String scenarioId = (String) values.get("scenarioId"); + 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 -> toMap(c, resourceGroupId)); + 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) { - CqnInsert insert = context.getCqn(); - List> entries = insert.entries(); + public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); - for (Map entry : entries) { + for (Configurations entry : entries) { String resourceGroupId = resolveResourceGroup(entry); - String name = (String) entry.get("name"); - String executableId = (String) entry.get("executableId"); - String scenarioId = (String) entry.get("scenarioId"); AiConfigurationBaseData request = AiConfigurationBaseData.create() - .name(name) - .executableId(executableId) - .scenarioId(scenarioId); + .name(entry.getName()) + .executableId(entry.getExecutableId()) + .scenarioId(entry.getScenarioId()); - @SuppressWarnings("unchecked") - List> paramBindings = - (List>) entry.get("parameterBindings"); + Collection paramBindings = + entry.getParameterBindings(); if (paramBindings != null) { List sdkBindings = paramBindings.stream() .map( p -> AiParameterArgumentBinding.create() - .key((String) p.get("key")) - .value((String) p.get("value"))) + .key(p.getKey()) + .value(p.getValue())) .toList(); request.parameterBindings(sdkBindings); } var response = configurationApi.create(resourceGroupId, request); - CdsData result = CdsData.create(entry); - result.put("id", response.getId()); - results.add(result); + entry.setId(response.getId()); + results.add(entry); logger.debug( "Created configuration {} in resource group {}", response.getId(), resourceGroupId); } context.setResult(results); } - private CdsData toMap(AiConfiguration config, String resourceGroupId) { - CdsData data = CdsData.create(); - data.put("id", config.getId()); - data.put("name", config.getName()); - data.put("executableId", config.getExecutableId()); - data.put("scenarioId", config.getScenarioId()); - data.put("createdAt", config.getCreatedAt()); + 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 = + List bindings = config.getParameterBindings().stream() .map( b -> { - CdsData bm = CdsData.create(); - bm.put("key", b.getKey()); - bm.put("value", b.getValue()); + ParameterArgumentBinding bm = ParameterArgumentBinding.create(); + bm.setKey(b.getKey()); + bm.setValue(b.getValue()); return bm; }) .toList(); - data.put("parameterBindings", bindings); + data.put(Configurations.PARAMETER_BINDINGS, bindings); } if (config.getInputArtifactBindings() != null) { - List bindings = + List bindings = config.getInputArtifactBindings().stream() .map( b -> { - CdsData bm = CdsData.create(); - bm.put("key", b.getKey()); - bm.put("artifactId", b.getArtifactId()); + ArtifactArgumentBinding bm = ArtifactArgumentBinding.create(); + bm.setKey(b.getKey()); + bm.setArtifactId(b.getArtifactId()); return bm; }) .toList(); - data.put("inputArtifactBindings", bindings); + data.put(Configurations.INPUT_ARTIFACT_BINDINGS, bindings); } - data.putPath("resourceGroup.resourceGroupId", resourceGroupId); + 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 index f1e6c76..931c2a4 100644 --- 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 @@ -10,15 +10,14 @@ 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.CdsData; import com.sap.cds.feature.aicore.core.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.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.ErrorStatuses; import com.sap.cds.services.ServiceException; @@ -57,53 +56,49 @@ public void onRead(CdsReadEventContext context) { String resourceGroupId = resolveResourceGroup(merge(keys, values)); - String id = (String) keys.get("id"); + String id = (String) keys.get(Deployments.ID); if (id != null) { AiDeploymentResponseWithDetails deployment = deploymentApi.get(resourceGroupId, id); - context.setResult(List.of(toMap(deployment, resourceGroupId))); + 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 -> toMap(d, resourceGroupId))); + context.setResult( + mapResources(result.getResources(), d -> toDeployments(d, resourceGroupId))); } } @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) - public void onCreate(CdsCreateEventContext context) { - CqnInsert insert = context.getCqn(); - List> entries = insert.entries(); + public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); - for (Map entry : entries) { + for (Deployments entry : entries) { String resourceGroupId = resolveResourceGroup(entry); - String configurationId = (String) entry.get("configurationId"); + String configurationId = entry.getConfigurationId(); AiDeploymentCreationRequest request = AiDeploymentCreationRequest.create().configurationId(configurationId); - if (entry.containsKey("ttl")) { - request.ttl((String) entry.get("ttl")); + if (entry.getTtl() != null) { + request.ttl(entry.getTtl()); } var response = deploymentApi.create(resourceGroupId, request); - CdsData result = CdsData.create(entry); - result.put("id", response.getId()); - result.put("status", response.getStatus().getValue()); - results.add(result); + 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) { - CqnUpdate update = context.getCqn(); - List> entries = update.entries(); + public void onUpdate(CdsUpdateEventContext context, List entries) { if (entries.isEmpty()) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); } - Map data = entries.get(0); - if (!data.containsKey("targetStatus") && !data.containsKey("configurationId")) { + 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'"); @@ -111,24 +106,23 @@ public void onUpdate(CdsUpdateEventContext context) { CdsModel model = context.getModel(); CqnAnalyzer analyzer = CqnAnalyzer.create(model); - Map keys = analyzer.analyze(update).targetKeys(); + Map keys = analyzer.analyze(context.getCqn()).targetKeys(); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(merge(keys, data)); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); - if (data.containsKey("targetStatus")) { - String targetStatus = (String) data.get("targetStatus"); - modRequest.targetStatus(AiDeploymentTargetStatus.fromValue(targetStatus)); + if (data.getTargetStatus() != null) { + modRequest.targetStatus(AiDeploymentTargetStatus.fromValue(data.getTargetStatus())); } - if (data.containsKey("configurationId")) { - modRequest.configurationId((String) data.get("configurationId")); + 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(CdsData.create(data))); + context.setResult(List.of(data)); } @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS) @@ -138,7 +132,7 @@ public void onDelete(CdsDeleteEventContext context) { CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(delete).targetKeys(); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(keys); deploymentApi.delete(resourceGroupId, deploymentId); @@ -147,90 +141,51 @@ public void onDelete(CdsDeleteEventContext context) { } // CPD-OFF - SDK types AiDeploymentResponseWithDetails and AiDeployment share no common interface - private CdsData toMap(AiDeploymentResponseWithDetails d, String resourceGroupId) { - return buildDeploymentData( - 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 CdsData toMap(AiDeployment d, String resourceGroupId) { - return buildDeploymentData( - 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( + AiDeploymentResponseWithDetails d, String resourceGroupId) { + Deployments data = Deployments.create(); + data.setId(d.getId()); + data.setDeploymentUrl(d.getDeploymentUrl()); + data.setConfigurationId(d.getConfigurationId()); + data.setConfigurationName(d.getConfigurationName()); + data.setExecutableId(d.getExecutableId()); + data.setScenarioId(d.getScenarioId()); + data.setStatus(d.getStatus().getValue()); + data.setStatusMessage(d.getStatusMessage()); + data.setTargetStatus(d.getTargetStatus().getValue()); + data.setLastOperation(d.getLastOperation() != null ? d.getLastOperation().getValue() : null); + data.setLatestRunningConfigurationId(d.getLatestRunningConfigurationId()); + data.setTtl(d.getTtl()); + data.put(Deployments.CREATED_AT, d.getCreatedAt()); + data.put(Deployments.MODIFIED_AT, d.getModifiedAt()); + data.put(Deployments.SUBMISSION_TIME, d.getSubmissionTime()); + data.put(Deployments.START_TIME, d.getStartTime()); + data.put(Deployments.COMPLETION_TIME, d.getCompletionTime()); + data.setResourceGroup(ResourceGroups.create(resourceGroupId)); + return data; } - // CPD-ON - - private static CdsData buildDeploymentData( - String id, - String deploymentUrl, - String configurationId, - String configurationName, - String executableId, - String scenarioId, - String status, - String statusMessage, - String targetStatus, - String lastOperation, - String latestRunningConfigurationId, - String ttl, - Object createdAt, - Object modifiedAt, - Object submissionTime, - Object startTime, - Object completionTime, - String resourceGroupId) { - CdsData data = CdsData.create(); - data.put("id", id); - data.put("deploymentUrl", deploymentUrl); - data.put("configurationId", configurationId); - data.put("configurationName", configurationName); - data.put("executableId", executableId); - data.put("scenarioId", scenarioId); - data.put("status", status); - data.put("statusMessage", statusMessage); - data.put("targetStatus", targetStatus); - data.put("lastOperation", lastOperation); - data.put("latestRunningConfigurationId", latestRunningConfigurationId); - data.put("ttl", ttl); - data.put("createdAt", createdAt); - data.put("modifiedAt", modifiedAt); - data.put("submissionTime", submissionTime); - data.put("startTime", startTime); - data.put("completionTime", completionTime); - data.putPath("resourceGroup.resourceGroupId", resourceGroupId); + private static Deployments toDeployments(AiDeployment d, String resourceGroupId) { + Deployments data = Deployments.create(); + data.setId(d.getId()); + data.setDeploymentUrl(d.getDeploymentUrl()); + data.setConfigurationId(d.getConfigurationId()); + data.setConfigurationName(d.getConfigurationName()); + data.setExecutableId(d.getExecutableId()); + data.setScenarioId(d.getScenarioId()); + data.setStatus(d.getStatus().getValue()); + data.setStatusMessage(d.getStatusMessage()); + data.setTargetStatus(d.getTargetStatus().getValue()); + data.setLastOperation(d.getLastOperation() != null ? d.getLastOperation().getValue() : null); + data.setLatestRunningConfigurationId(d.getLatestRunningConfigurationId()); + data.setTtl(d.getTtl()); + data.put(Deployments.CREATED_AT, d.getCreatedAt()); + data.put(Deployments.MODIFIED_AT, d.getModifiedAt()); + data.put(Deployments.SUBMISSION_TIME, d.getSubmissionTime()); + data.put(Deployments.START_TIME, d.getStartTime()); + data.put(Deployments.COMPLETION_TIME, d.getCompletionTime()); + data.setResourceGroup(ResourceGroups.create(resourceGroupId)); return data; } + // CPD-ON } 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 index 1037b68..aa61f7f 100644 --- 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 @@ -12,10 +12,10 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.aicore.core.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.CqnInsert; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsModel; @@ -53,9 +53,9 @@ public void onRead(CdsReadEventContext context) { Map keys = analysis.targetKeys(); Map values = analysis.targetValues(); - String resourceGroupId = (String) keys.get("resourceGroupId"); + String resourceGroupId = (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); if (resourceGroupId == null) { - resourceGroupId = (String) values.get("resourceGroupId"); + resourceGroupId = (String) values.get(ResourceGroups.RESOURCE_GROUP_ID); } if (resourceGroupId != null) { @@ -63,8 +63,8 @@ public void onRead(CdsReadEventContext context) { context.setResult(List.of(toMap(rg))); } else { List labelSelector = null; - if (values.containsKey("tenantId")) { - String tenantId = (String) values.get("tenantId"); + if (values.containsKey(ResourceGroups.TENANT_ID)) { + String tenantId = (String) values.get(ResourceGroups.TENANT_ID); labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); } BckndResourceGroupList result = @@ -74,18 +74,16 @@ public void onRead(CdsReadEventContext context) { } @On(event = CqnService.EVENT_CREATE, entity = AICoreService.RESOURCE_GROUPS) - public void onCreate(CdsCreateEventContext context) { - CqnInsert insert = context.getCqn(); - List> entries = insert.entries(); + public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); - for (Map entry : entries) { - String resourceGroupId = (String) entry.get("resourceGroupId"); + for (ResourceGroups entry : entries) { + String resourceGroupId = entry.getResourceGroupId(); BckndResourceGroupsPostRequest request = BckndResourceGroupsPostRequest.create().resourceGroupId(resourceGroupId); @SuppressWarnings("unchecked") - List> labels = (List>) entry.get("labels"); + 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 @@ -95,12 +93,11 @@ public void onCreate(CdsCreateEventContext context) { && labels.stream() .anyMatch(l -> AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.get("key"))); - if (entry.containsKey("tenantId") && !userSuppliedTenantLabel) { - String tenantId = (String) entry.get("tenantId"); + if (entry.getTenantId() != null && !userSuppliedTenantLabel) { mergedLabels.add( BckndResourceGroupLabel.create() .key(AICoreServiceImpl.TENANT_LABEL_KEY) - .value(tenantId)); + .value(entry.getTenantId())); } if (labels != null) { @@ -131,7 +128,7 @@ public void onUpdate(CdsUpdateEventContext context) { BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @SuppressWarnings("unchecked") - List> labels = (List>) data.get("labels"); + List> labels = (List>) data.get(ResourceGroups.LABELS); if (labels != null) { patchRequest.labels(toSdkLabels(labels)); } @@ -155,11 +152,11 @@ public void onDelete(CdsDeleteEventContext context) { } private String resolveResourceGroupId(Map keys) { - if (keys.containsKey("resourceGroupId")) { - return (String) keys.get("resourceGroupId"); + if (keys.containsKey(ResourceGroups.RESOURCE_GROUP_ID)) { + return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); } - if (keys.containsKey("tenantId")) { - return service.resourceGroupForTenant((String) keys.get("tenantId")); + if (keys.containsKey(ResourceGroups.TENANT_ID)) { + return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); } return service.getDefaultResourceGroup(); } @@ -174,24 +171,25 @@ private static List toSdkLabels(List labels = new ArrayList<>(rg.getLabels().size()); for (BckndResourceGroupLabel l : rg.getLabels()) { - CdsData lm = CdsData.create(); - lm.put("key", l.getKey()); - lm.put("value", l.getValue()); + 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.put("tenantId", l.getValue()); + data.setTenantId(l.getValue()); } } - data.put("labels", labels); + data.put(ResourceGroups.LABELS, labels); } return data; } 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 index 9b4da06..00fe4a0 100644 --- 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 @@ -10,7 +10,7 @@ import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.cds.CdsUpdateEventContext; @@ -28,7 +28,6 @@ class DeploymentHandlerTest { @Mock private AICoreServiceImpl service; @Mock private DeploymentApi deploymentApi; @Mock private CdsUpdateEventContext context; - @Mock private CqnUpdate update; private DeploymentHandler cut; @@ -40,10 +39,9 @@ void setup() { @Test void onUpdate_emptyEntries_throwsBadRequest() { - when(context.getCqn()).thenReturn(update); - when(update.entries()).thenReturn(List.of()); + List entries = List.of(); - assertThatThrownBy(() -> cut.onUpdate(context)) + assertThatThrownBy(() -> cut.onUpdate(context, entries)) .isInstanceOfSatisfying( ServiceException.class, e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) @@ -54,10 +52,9 @@ void onUpdate_emptyEntries_throwsBadRequest() { @Test void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { - when(context.getCqn()).thenReturn(update); - when(update.entries()).thenReturn(List.of(Map.of("ttl", "1d"))); + List entries = List.of(Deployments.of(Map.of("ttl", "1d"))); - assertThatThrownBy(() -> cut.onUpdate(context)) + assertThatThrownBy(() -> cut.onUpdate(context, entries)) .isInstanceOfSatisfying( ServiceException.class, e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) 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 index 8c9743a..925737d 100644 --- 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 @@ -12,7 +12,7 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.ql.cqn.CqnInsert; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.services.cds.CdsCreateEventContext; import java.util.List; import java.util.Map; @@ -29,7 +29,6 @@ class ResourceGroupHandlerTest { @Mock private AICoreServiceImpl service; @Mock private ResourceGroupApi resourceGroupApi; @Mock private CdsCreateEventContext context; - @Mock private CqnInsert insert; private ResourceGroupHandler handler; @@ -42,10 +41,9 @@ void setUp() { @Test void onCreate_withTenantIdOnly_setsOnlyTenantLabel() { Map entry = Map.of("resourceGroupId", "rg-1", "tenantId", "tenant-a"); - when(context.getCqn()).thenReturn(insert); - when(insert.entries()).thenReturn(List.of(entry)); + List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context); + handler.onCreate(context, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getResourceGroupId()).isEqualTo("rg-1"); @@ -62,10 +60,9 @@ void onCreate_withLabelsOnly_setsOnlyUserLabels() { "rg-2", "labels", List.of(Map.of("key", "env", "value", "prod"), Map.of("key", "team", "value", "ai"))); - when(context.getCqn()).thenReturn(insert); - when(insert.entries()).thenReturn(List.of(entry)); + List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context); + handler.onCreate(context, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getResourceGroupId()).isEqualTo("rg-2"); @@ -84,10 +81,9 @@ void onCreate_withTenantIdAndLabels_keepsTenantLabelAndUserLabels() { "tenant-b", "labels", List.of(Map.of("key", "env", "value", "prod"))); - when(context.getCqn()).thenReturn(insert); - when(insert.entries()).thenReturn(List.of(entry)); + List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context); + handler.onCreate(context, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); // Tenant label first, then user-supplied labels — and tenant label is NOT lost. @@ -107,10 +103,9 @@ void onCreate_userSuppliedTenantLabelTakesPrecedence() { "tenant-auto", "labels", List.of(Map.of("key", AICoreServiceImpl.TENANT_LABEL_KEY, "value", "tenant-user"))); - when(context.getCqn()).thenReturn(insert); - when(insert.entries()).thenReturn(List.of(entry)); + List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context); + handler.onCreate(context, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getLabels()) From 3fe47d2c4207cb3c8b0759c5e591548384ba58b5 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Wed, 27 May 2026 15:05:24 +0200 Subject: [PATCH 27/42] refactor: slim AICoreService interface to public API surface (#42) * fix: use idiomatic patterns for error messages and stream collection - Replace string concatenation in ServiceException messages with SLF4J-style {} placeholders in AICoreSetupHandler (3 locations) - Replace Collectors.toList() with .toList() in AbstractCrudHandler - Replace new ArrayList<>() with List.of() for null case - Remove unused imports (ArrayList, Collectors) Closes #32 * refactor: use cds-maven-plugin generated interfaces for type-safe entity access Replace manual AICoreElements constants class with generated typed interfaces from cds-maven-plugin (following cds-feature-attachments pattern): - Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml - Generated interfaces: Deployments, Configurations, ResourceGroups, BckndResourceGroupLabel (with constants + typed getters/setters) - Update all handlers to use generated constants (e.g., Deployments.ID) and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id)) - Remove manual AICoreElements.java (replaced by code generation) - DeploymentHandler: use Deployments.create() with typed setters instead of 17-parameter buildDeploymentData method Closes #33 * refactor: slim AICoreService interface to public API surface Remove implementation-detail methods from AICoreService interface: - getDefaultResourceGroup() - getResourceGroupPrefix() - getTenantResourceGroupCache() - getResourceGroupDeploymentCache() - clearTenantCache() - resolveResourceGroupFromKeys() These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl but are no longer part of the service contract. Only domain-level API (resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled) and getRetry() (cross-module consumer) remain on the interface. Closes #34 * refactor: introduce AbstractAICoreService base class for shared internal methods Extract getDefaultResourceGroup(), getResourceGroupPrefix(), getTenantResourceGroupCache(), getResourceGroupDeploymentCache(), clearTenantCache(), and resolveResourceGroupFromKeys() into an abstract base class that both AICoreServiceImpl and MockAICoreServiceImpl extend. This keeps the public AICoreService interface slim (domain API only) while providing a typed common base for tests and internal consumers that need access to cache/config methods. Update all integration and MTX test files to use the new getAICoreServiceImpl() helper that returns AbstractAICoreService. Closes #34 --- .../feature/aicore/core/AICoreService.java | 13 ------ .../aicore/core/AICoreServiceImpl.java | 9 +--- .../aicore/core/AbstractAICoreService.java | 41 +++++++++++++++++++ .../aicore/core/MockAICoreServiceImpl.java | 9 +--- .../aicore/itest/mt/MtxLifecycleTest.java | 9 ++-- .../itest/mt/SubscribeUnsubscribeTest.java | 9 ++-- .../aicore/itest/mt/TenantIsolationTest.java | 13 +++--- .../aicore/itest/AICoreServiceTest.java | 11 ++--- .../cds/feature/aicore/itest/ActionTest.java | 17 ++++---- .../aicore/itest/BaseIntegrationTest.java | 7 +++- .../aicore/itest/ConfigurationTest.java | 9 ++-- .../feature/aicore/itest/DeploymentTest.java | 7 ++-- .../aicore/itest/MultiTenancyTest.java | 13 +++--- 13 files changed, 97 insertions(+), 70 deletions(-) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java index 138076e..91a3f3d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java @@ -6,7 +6,6 @@ import com.sap.cds.services.cds.CqnService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import io.github.resilience4j.retry.Retry; -import java.util.Map; public interface AICoreService extends CqnService { @@ -24,16 +23,4 @@ public interface AICoreService extends CqnService { boolean isMultiTenancyEnabled(); Retry getRetry(); - - String getDefaultResourceGroup(); - - String getResourceGroupPrefix(); - - Map getTenantResourceGroupCache(); - - Map getResourceGroupDeploymentCache(); - - void clearTenantCache(String tenantId); - - String resolveResourceGroupFromKeys(Map keys); } 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 index 2c0d4b6..da33d58 100644 --- 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 @@ -25,7 +25,6 @@ import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cds.services.utils.services.AbstractCqnService; 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; @@ -38,7 +37,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class AICoreServiceImpl extends AbstractCqnService implements AICoreService { +public class AICoreServiceImpl extends AbstractAICoreService { private static final Logger logger = LoggerFactory.getLogger(AICoreServiceImpl.class); @@ -156,22 +155,18 @@ 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(); } @@ -192,7 +187,6 @@ public ResourceGroupApi getResourceGroupApi() { return resourceGroupApi; } - @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { return (String) keys.get("resourceGroup_resourceGroupId"); @@ -205,7 +199,6 @@ public String resolveResourceGroupFromKeys(Map keys) { return resourceGroupForTenant(tenantId); } - @Override public void clearTenantCache(String tenantId) { String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); if (resourceGroupId != null) { 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..5919415 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -0,0 +1,41 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.utils.services.AbstractCqnService; +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 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 index c13a197..ee08623 100644 --- 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 @@ -5,7 +5,6 @@ import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cds.services.utils.services.AbstractCqnService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryConfig; @@ -14,7 +13,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class MockAICoreServiceImpl extends AbstractCqnService implements AICoreService { +public class MockAICoreServiceImpl extends AbstractAICoreService { private static final Logger logger = LoggerFactory.getLogger(MockAICoreServiceImpl.class); @@ -68,27 +67,22 @@ 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) { @@ -99,7 +93,6 @@ public void clearTenantCache(String tenantId) { } } - @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { return (String) keys.get("resourceGroup_resourceGroupId"); 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 index cc5ee9c..645d9b0 100644 --- 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 @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.core.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; @@ -48,7 +49,7 @@ void tearDown() { @Test void unsubscribe_isIdempotent() throws Exception { - AICoreService service = getService(); + AbstractAICoreService service = getService(); subscriptionEndpointClient.subscribeTenant(TENANT); subscriptionEndpointClient.unsubscribeTenant(TENANT); @@ -60,7 +61,7 @@ void unsubscribe_isIdempotent() throws Exception { @Test void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { - AICoreService service = getService(); + AbstractAICoreService service = getService(); for (int i = 0; i < 2; i++) { subscriptionEndpointClient.subscribeTenant(TENANT); @@ -71,7 +72,7 @@ void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { } } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + 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 index f35f3b9..ce9deb9 100644 --- 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 @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.core.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; @@ -50,7 +51,7 @@ void subscribeTenant_thenServiceIsReachable() throws Exception { @Test void subscribeTenant_createsResourceGroup() throws Exception { - AICoreService service = getService(); + AbstractAICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-3"); @@ -60,7 +61,7 @@ void subscribeTenant_createsResourceGroup() throws Exception { @Test void unsubscribeTenant_clearsCaches() throws Exception { - AICoreService service = getService(); + AbstractAICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-3"); @@ -94,7 +95,7 @@ void tearDown() { } } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + 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 index fb42ad5..2db4b7e 100644 --- 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 @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.core.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; @@ -36,13 +37,13 @@ void setup() { @Test void multiTenancyEnabled() { - AICoreService service = getService(); + AbstractAICoreService service = getService(); assertThat(service.isMultiTenancyEnabled()).isTrue(); } @Test void differentTenants_getDifferentResourceGroups() throws Exception { - AICoreService service = getService(); + AbstractAICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); @@ -57,7 +58,7 @@ void differentTenants_getDifferentResourceGroups() throws Exception { @Test void resourceGroupPrefix_applied() throws Exception { - AICoreService service = getService(); + AbstractAICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); String rg = service.getTenantResourceGroupCache().get("tenant-1"); @@ -67,7 +68,7 @@ void resourceGroupPrefix_applied() throws Exception { @Test void clearTenantCache_onlyAffectsTarget() throws Exception { - AICoreService service = getService(); + AbstractAICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); @@ -80,8 +81,8 @@ void clearTenantCache_onlyAffectsTarget() throws Exception { assertThat(service.getTenantResourceGroupCache()).containsEntry("tenant-2", rg2); } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private AbstractAICoreService getService() { + return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } @AfterEach 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 index 2087db0..78e91e0 100644 --- 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 @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.recommendation.RptModelSpec; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -27,7 +28,7 @@ void service_isRegistered() { @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); if (!service.isMultiTenancyEnabled()) { String result = service.resourceGroupForTenant("any-tenant"); assertThat(result).isEqualTo(service.getDefaultResourceGroup()); @@ -36,7 +37,7 @@ void resourceGroupForTenant_singleTenancy_returnsDefault() { @Test void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); if (service.isMultiTenancyEnabled()) { String tenantId = "itest-svc-tenant-" + System.currentTimeMillis(); try { @@ -55,7 +56,7 @@ void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { @Test void deploymentId_returnsDeploymentId() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); String resourceGroup = service.getDefaultResourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); @@ -68,7 +69,7 @@ void deploymentId_returnsDeploymentId() { @Test void clearTenantCache_removesEntries() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); String tenantId = "itest-cache-tenant"; String fakeRg = "fake-rg"; String fakeKey = fakeRg + "::" + RptModelSpec.CONFIG_NAME; @@ -83,7 +84,7 @@ void clearTenantCache_removesEntries() { @Test void configProperties_areApplied() { - AICoreService service = getAICoreService(); + 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 index b098f7f..caf4a4c 100644 --- 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 @@ -10,6 +10,7 @@ import com.sap.cds.Result; import com.sap.cds.Row; import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.recommendation.RptModelSpec; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; @@ -27,12 +28,12 @@ class ActionTest extends BaseIntegrationTest { @BeforeAll void ensureResourceGroupReady() { - ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreService().getDefaultResourceGroup()); + ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreServiceImpl().getDefaultResourceGroup()); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); String result = service.resourceGroupForTenant("any-tenant-id"); assertThat(result).isEqualTo(service.getDefaultResourceGroup()); @@ -40,7 +41,7 @@ void resourceGroupForTenant_singleTenancy_returnsDefault() { @Test void resourceGroupForTenant_multiTenancy_createsGroup() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantId = "itest-action-tenant-" + System.currentTimeMillis(); try { @@ -54,7 +55,7 @@ void resourceGroupForTenant_multiTenancy_createsGroup() { @Test void deploymentId_returnsValidDeployment() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); String resourceGroup = service.getDefaultResourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); @@ -63,7 +64,7 @@ void deploymentId_returnsValidDeployment() { @Test void deploymentId_cachedOnSecondCall() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); String resourceGroup = service.getDefaultResourceGroup(); String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); @@ -76,7 +77,7 @@ void deploymentId_cachedOnSecondCall() { @Test void stop_deployment_changesTargetStatus() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); Result deployments = service.run( @@ -116,7 +117,7 @@ void stop_deployment_changesTargetStatus() { @Test void resolveResourceGroupFromKeys_directKey() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); Map keys = Map.of("resourceGroup_resourceGroupId", "my-rg"); String resolved = service.resolveResourceGroupFromKeys(keys); assertThat(resolved).isEqualTo("my-rg"); @@ -124,7 +125,7 @@ void resolveResourceGroupFromKeys_directKey() { @Test void resolveResourceGroupFromKeys_nestedMap() { - AICoreService service = getAICoreService(); + 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/BaseIntegrationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java index 031d035..51abd19 100644 --- 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 @@ -5,6 +5,7 @@ import com.sap.cds.Result; import com.sap.cds.Row; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.core.AICoreService; import com.sap.cds.feature.recommendation.RptModelSpec; import com.sap.cds.ql.Insert; @@ -41,12 +42,16 @@ 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 = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); return CACHED_DEPLOYMENT_IDS.computeIfAbsent( resourceGroup, rg -> { 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 index de158ac..9516dd1 100644 --- 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 @@ -7,6 +7,7 @@ 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; @@ -19,7 +20,7 @@ class ConfigurationTest extends BaseIntegrationTest { @Test void readAll_returnsConfigurations() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); Result result = service.run( Select.from("AICore.configurations") @@ -31,7 +32,7 @@ void readAll_returnsConfigurations() { @Test void readAll_filterByScenario() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); Result result = service.run( Select.from("AICore.configurations") @@ -47,7 +48,7 @@ void readAll_filterByScenario() { @Test void create_andReadById() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); String configName = "itest-config-" + System.currentTimeMillis(); Result created = @@ -92,7 +93,7 @@ void create_andReadById() { @Test void create_withParameterBindings_mapsCorrectly() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); String configName = "itest-params-" + System.currentTimeMillis(); Result created = 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 index 227c4a5..7dae971 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -20,7 +21,7 @@ class DeploymentTest extends BaseIntegrationTest { @Test void readAll_returnsDeployments() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); Result result = service.run( Select.from("AICore.deployments") @@ -32,7 +33,7 @@ void readAll_returnsDeployments() { @Test void readSingle_returnsDeploymentDetails() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); Result all = service.run( Select.from("AICore.deployments") @@ -62,7 +63,7 @@ void readSingle_returnsDeploymentDetails() { @Test void update_targetStatus_stopsRunningDeployment() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreService().getDefaultResourceGroup(); + String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); Result deployments = service.run( 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 index 4d47c8c..35a7226 100644 --- 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 @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.core.AbstractAICoreService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -18,7 +19,7 @@ class MultiTenancyTest extends BaseIntegrationTest { @AfterEach void cleanup() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); if (tenantA != null) { service.clearTenantCache(tenantA); tenantA = null; @@ -31,7 +32,7 @@ void cleanup() { @Test void differentTenants_getDifferentResourceGroups() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); tenantA = "itest-mt-a-" + System.currentTimeMillis(); tenantB = "itest-mt-b-" + System.currentTimeMillis(); @@ -46,7 +47,7 @@ void differentTenants_getDifferentResourceGroups() { @Test void resourceGroupPrefix_appliedCorrectly() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); tenantA = "itest-prefix-" + System.currentTimeMillis(); @@ -56,7 +57,7 @@ void resourceGroupPrefix_appliedCorrectly() { @Test void cacheIsolation_perTenant() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); tenantA = "itest-cache-a-" + System.currentTimeMillis(); tenantB = "itest-cache-b-" + System.currentTimeMillis(); @@ -70,7 +71,7 @@ void cacheIsolation_perTenant() { @Test void clearTenantCache_onlyAffectsTargetTenant() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); tenantA = "itest-clear-a-" + System.currentTimeMillis(); tenantB = "itest-clear-b-" + System.currentTimeMillis(); @@ -86,7 +87,7 @@ void clearTenantCache_onlyAffectsTargetTenant() { @Test void singleTenancy_alwaysReturnsDefault() { - AICoreService service = getAICoreService(); + AbstractAICoreService service = getAICoreServiceImpl(); assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); String rg1 = service.resourceGroupForTenant("tenant-x"); String rg2 = service.resourceGroupForTenant("tenant-y"); From 7368c7f1d0c53f6be9a85bad7b7c2755cd5ccfc0 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Wed, 27 May 2026 16:01:30 +0200 Subject: [PATCH 28/42] fix: cleanup sonar-* resource groups in CI (#38) (#46) The integration-tests-cleanup job now: - Waits for both integration-tests and sonarqube-scan to finish - Deletes resource groups with both itest- and sonar- prefixes Closes #38 --- .github/workflows/pipeline.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index c13335f..361896b 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -73,7 +73,7 @@ jobs: name: Cleanup Integration Test Resources runs-on: ubuntu-latest timeout-minutes: 10 - needs: [integration-tests] + needs: [integration-tests, sonarqube-scan] if: always() permissions: contents: read @@ -118,10 +118,10 @@ jobs: const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); const token = JSON.parse(tokenRes.body).access_token; const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; - const prefix = 'itest-${{ github.run_id }}-${{ github.run_attempt }}'; + const prefixes = ['itest-${{ github.run_id }}-${{ github.run_attempt }}', 'sonar-${{ github.run_id }}-${{ github.run_attempt }}']; const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); const groups = JSON.parse(rgRes.body).resources || []; - const toDelete = groups.filter(rg => rg.resourceGroupId && rg.resourceGroupId.startsWith(prefix)); + const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); for (const rg of toDelete) { const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); console.log('Delete', rg.resourceGroupId, '->', res.status); From 27b95c3315755704f789705e3e078573137882e3 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Thu, 28 May 2026 10:13:21 +0200 Subject: [PATCH 29/42] refactor: decompose FioriRecommendationHandler into focused helpers (#43) * fix: use idiomatic patterns for error messages and stream collection - Replace string concatenation in ServiceException messages with SLF4J-style {} placeholders in AICoreSetupHandler (3 locations) - Replace Collectors.toList() with .toList() in AbstractCrudHandler - Replace new ArrayList<>() with List.of() for null case - Remove unused imports (ArrayList, Collectors) Closes #32 * refactor: use cds-maven-plugin generated interfaces for type-safe entity access Replace manual AICoreElements constants class with generated typed interfaces from cds-maven-plugin (following cds-feature-attachments pattern): - Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml - Generated interfaces: Deployments, Configurations, ResourceGroups, BckndResourceGroupLabel (with constants + typed getters/setters) - Update all handlers to use generated constants (e.g., Deployments.ID) and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id)) - Remove manual AICoreElements.java (replaced by code generation) - DeploymentHandler: use Deployments.create() with typed setters instead of 17-parameter buildDeploymentData method Closes #33 * refactor: slim AICoreService interface to public API surface Remove implementation-detail methods from AICoreService interface: - getDefaultResourceGroup() - getResourceGroupPrefix() - getTenantResourceGroupCache() - getResourceGroupDeploymentCache() - clearTenantCache() - resolveResourceGroupFromKeys() These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl but are no longer part of the service contract. Only domain-level API (resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled) and getRetry() (cross-module consumer) remain on the interface. Closes #34 * refactor: decompose FioriRecommendationHandler into focused helpers Extract two helper classes from the 400-line handler: - RecommendationContextBuilder: entity analysis, prediction element discovery, context query building, synthetic key computation, and row assembly - RecommendationResultParser: prediction value type coercion, text path resolution from annotations, description DB lookups, and final recommendations map assembly The handler is now a slim orchestrator (~110 lines) that delegates complex logic to the helpers. Also replace unbounded ConcurrentHashMap.newKeySet() with a bounded Caffeine cache (max 10,000 entries) for the entity negative-result cache. Closes #35 * fix: add explicit caffeine dependency; make computeSyntheticKey private - Add caffeine dependency to cds-feature-recommendations/pom.xml (previously relied on transitive from cds-feature-ai-core) - Make computeSyntheticKey private in RecommendationContextBuilder (only called internally from assembleRows) --- cds-feature-recommendations/pom.xml | 5 + .../FioriRecommendationHandler.java | 330 ++---------------- .../RecommendationContextBuilder.java | 188 ++++++++++ .../RecommendationResultParser.java | 182 ++++++++++ 4 files changed, 405 insertions(+), 300 deletions(-) create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java diff --git a/cds-feature-recommendations/pom.xml b/cds-feature-recommendations/pom.xml index 9f1352e..68d09bd 100644 --- a/cds-feature-recommendations/pom.xml +++ b/cds-feature-recommendations/pom.xml @@ -25,6 +25,11 @@ cds-feature-ai-core + + com.github.ben-manes.caffeine + caffeine + + com.sap.ai.sdk.foundationmodels sap-rpt 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 index 46aef51..2af07ba 100644 --- 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 @@ -3,17 +3,10 @@ */ package com.sap.cds.feature.recommendation; -import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; - +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import com.sap.cds.CdsData; -import com.sap.cds.Result; import com.sap.cds.feature.aicore.core.AICoreService; -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.CdsElement; -import com.sap.cds.reflect.CdsSimpleType; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.cds.CdsReadEventContext; @@ -23,57 +16,23 @@ import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.utils.DraftUtils; -import java.math.BigDecimal; import java.util.ArrayList; -import java.util.EnumSet; -import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @ServiceName(value = "*", type = ApplicationService.class) class FioriRecommendationHandler implements EventHandler { - private final AICoreService aiCoreService; - private final RecommendationClientResolver clientResolver; - private final Set entitiesWithoutPredictions = ConcurrentHashMap.newKeySet(); private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); - private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; - private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = - "@Common.ValueListWithFixedValues"; private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; - 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 AICoreService aiCoreService; + private final RecommendationClientResolver clientResolver; + private final RecommendationResultParser resultParser = new RecommendationResultParser(); + private final Cache entitiesWithoutPredictions = + Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( AICoreService aiCoreService, RecommendationClientResolver clientResolver) { @@ -88,7 +47,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } String entityName = target.getQualifiedName(); - if (entitiesWithoutPredictions.contains(entityName)) { + if (entitiesWithoutPredictions.getIfPresent(entityName) != null) { return; } @@ -110,47 +69,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { if (rowType == null) { rowType = target; } - List predictionElementNames = - rowType - .elements() - .filter( - byAnnotation(VALUE_LIST_ANNOTATION) - .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) - .filter(e -> !e.getType().isAssociation()) - .map(CdsElement::getName) - .toList(); - if (predictionElementNames.isEmpty()) { - entitiesWithoutPredictions.add(entityName); - return; - } - List contextColumns = - 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(); - if (contextColumns.isEmpty()) { - logger.debug("No suitable context columns found, skipping predictions."); - return; - } - - List keyNames = target.keyElements().map(CdsElement::getName).toList(); - boolean syntheticKeyNeeded = - keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0))); - String indexColumn = - syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID"); - - List selectColumns = new ArrayList<>(contextColumns); - for (String key : keyNames) { - if (!selectColumns.contains(key)) { - selectColumns.add(key); - } - } int limit = context .getCdsRuntime() @@ -159,52 +78,41 @@ public void afterRead(CdsReadEventContext context, List dataList) { "cds.requires.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); - var select = - Select.from(target.getQualifiedName()) - .columns(selectColumns.toArray(String[]::new)) - .where( - predictionElementNames.stream() - .map(col -> CQL.get(col).isNotNull()) - .collect(CQL.withAnd())) - .limit(limit); - 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())); + + var builder = new RecommendationContextBuilder(target, rowType, limit); + + if (builder.predictionElementNames().isEmpty()) { + entitiesWithoutPredictions.put(entityName, 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(select).list()); + List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); if (contextRows.size() < 2) { logger.debug("Not enough context rows (minimum 2), skipping predictions."); return; } - CdsData predictRow = buildPredictRow(row, predictionElementNames); + CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { + logger.debug("Current row already has values for all prediction columns, skipping."); return; } - List allRows = new ArrayList<>(); - if (syntheticKeyNeeded) { - for (CdsData contextRow : contextRows) { - contextRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(contextRow, keyNames)); - allRows.add(contextRow); - } - predictRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(row, keyNames)); - } else { - allRows.addAll(contextRows); - } - allRows.add(predictRow); + List allRows = builder.assembleRows(contextRows, predictRow, row); String tenantId = context.getUserInfo().getTenant(); RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); - List predictions = client.predict(allRows, predictionElementNames, indexColumn); + List predictions = + client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); @@ -216,188 +124,10 @@ public void afterRead(CdsReadEventContext context, List dataList) { } List missingPredictionElementNames = - predictionElementNames.stream().filter(c -> row.get(c) == null).toList(); + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); Map recommendations = - buildRecommendations(db, predictions.get(0), missingPredictionElementNames, context, rowType); + resultParser.buildRecommendations( + db, predictions.get(0), missingPredictionElementNames, context, rowType); row.put("SAP_Recommendations", recommendations); } - - private CdsData buildPredictRow(CdsData row, List predictionElementNames) { - if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { - logger.debug("Current row already has values for all prediction columns, skipping."); - 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, List keyNames) { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < keyNames.size(); i++) { - if (i > 0) { - sb.append('\0'); - } - sb.append(keyNames.get(i)); - sb.append('\0'); - Object value = row.get(keyNames.get(i)); - if (value != null) { - sb.append(value); - } - } - return sb.toString(); - } - - private 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, "")); - values.put("RecommendedFieldScoreValue", 0.5); - values.put("RecommendedFieldIsSuggestion", true); - recommendations.put(col, List.of(values)); - } - return recommendations; - } - - 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 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 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/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java new file mode 100644 index 0000000..deb3d7c --- /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-feature-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/RecommendationResultParser.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java new file mode 100644 index 0000000..5685ed0 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java @@ -0,0 +1,182 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-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, "")); + 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(); + }); + } +} From c18914851a2c292316276d1802b3ed666dbc5699 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Thu, 28 May 2026 10:33:55 +0200 Subject: [PATCH 30/42] refactor: accept SDK API clients as constructor parameters (#44) * fix: use idiomatic patterns for error messages and stream collection - Replace string concatenation in ServiceException messages with SLF4J-style {} placeholders in AICoreSetupHandler (3 locations) - Replace Collectors.toList() with .toList() in AbstractCrudHandler - Replace new ArrayList<>() with List.of() for null case - Remove unused imports (ArrayList, Collectors) Closes #32 * refactor: use cds-maven-plugin generated interfaces for type-safe entity access Replace manual AICoreElements constants class with generated typed interfaces from cds-maven-plugin (following cds-feature-attachments pattern): - Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml - Generated interfaces: Deployments, Configurations, ResourceGroups, BckndResourceGroupLabel (with constants + typed getters/setters) - Update all handlers to use generated constants (e.g., Deployments.ID) and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id)) - Remove manual AICoreElements.java (replaced by code generation) - DeploymentHandler: use Deployments.create() with typed setters instead of 17-parameter buildDeploymentData method Closes #33 * refactor: slim AICoreService interface to public API surface Remove implementation-detail methods from AICoreService interface: - getDefaultResourceGroup() - getResourceGroupPrefix() - getTenantResourceGroupCache() - getResourceGroupDeploymentCache() - clearTenantCache() - resolveResourceGroupFromKeys() These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl but are no longer part of the service contract. Only domain-level API (resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled) and getRetry() (cross-module consumer) remain on the interface. Closes #34 * refactor: decompose FioriRecommendationHandler into focused helpers Extract two helper classes from the 400-line handler: - RecommendationContextBuilder: entity analysis, prediction element discovery, context query building, synthetic key computation, and row assembly - RecommendationResultParser: prediction value type coercion, text path resolution from annotations, description DB lookups, and final recommendations map assembly The handler is now a slim orchestrator (~110 lines) that delegates complex logic to the helpers. Also replace unbounded ConcurrentHashMap.newKeySet() with a bounded Caffeine cache (max 10,000 entries) for the entity negative-result cache. Closes #35 * refactor: accept SDK API clients as constructor parameters Add overloaded constructor to AICoreServiceImpl that accepts DeploymentApi, ConfigurationApi, ResourceGroupApi, and AiCoreService as parameters. The existing convenience constructor delegates to it with default instances. This enables direct injection of mocked clients in unit tests without needing to mock getter methods on the service itself. Closes #36 * refactor: remove convenience constructor, pass API clients explicitly Remove the delegating convenience constructor from AICoreServiceImpl. The only caller (AICoreServiceConfiguration) now passes DeploymentApi, ConfigurationApi, ResourceGroupApi, and AiCoreService explicitly, making the dependency wiring fully transparent at the creation site. Closes #36 --- .../aicore/core/AICoreServiceConfiguration.java | 13 ++++++++++++- .../feature/aicore/core/AICoreServiceImpl.java | 17 ++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) 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 index 2d6c372..eca6c25 100644 --- 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 @@ -3,6 +3,10 @@ */ package com.sap.cds.feature.aicore.core; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; @@ -48,7 +52,14 @@ public void services(CdsRuntimeConfigurer configurer) { if (hasBinding) { AICoreServiceImpl service = - new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + 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 { 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 index da33d58..207de4b 100644 --- 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 @@ -65,7 +65,14 @@ public class AICoreServiceImpl extends AbstractAICoreService { private final ResourceGroupApi resourceGroupApi; private final AiCoreService sdkService; - public AICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { + 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(); @@ -82,10 +89,10 @@ public AICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEn this.tenantResourceGroupCache = newCache(); this.resourceGroupDeploymentCache = newCache(); this.deploymentLocks = newCache(); - this.deploymentApi = new DeploymentApi(); - this.configurationApi = new ConfigurationApi(); - this.resourceGroupApi = new ResourceGroupApi(); - this.sdkService = new AiCoreService(); + this.deploymentApi = deploymentApi; + this.configurationApi = configurationApi; + this.resourceGroupApi = resourceGroupApi; + this.sdkService = sdkService; } private static Cache newCache() { From 24abc831e45928db903c1b39ce20aef0e0ef4754 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Thu, 28 May 2026 12:27:53 +0200 Subject: [PATCH 31/42] docs: document RptInferenceClient as intentional public API (#45) * fix: use idiomatic patterns for error messages and stream collection - Replace string concatenation in ServiceException messages with SLF4J-style {} placeholders in AICoreSetupHandler (3 locations) - Replace Collectors.toList() with .toList() in AbstractCrudHandler - Replace new ArrayList<>() with List.of() for null case - Remove unused imports (ArrayList, Collectors) Closes #32 * refactor: use cds-maven-plugin generated interfaces for type-safe entity access Replace manual AICoreElements constants class with generated typed interfaces from cds-maven-plugin (following cds-feature-attachments pattern): - Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml - Generated interfaces: Deployments, Configurations, ResourceGroups, BckndResourceGroupLabel (with constants + typed getters/setters) - Update all handlers to use generated constants (e.g., Deployments.ID) and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id)) - Remove manual AICoreElements.java (replaced by code generation) - DeploymentHandler: use Deployments.create() with typed setters instead of 17-parameter buildDeploymentData method Closes #33 * refactor: slim AICoreService interface to public API surface Remove implementation-detail methods from AICoreService interface: - getDefaultResourceGroup() - getResourceGroupPrefix() - getTenantResourceGroupCache() - getResourceGroupDeploymentCache() - clearTenantCache() - resolveResourceGroupFromKeys() These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl but are no longer part of the service contract. Only domain-level API (resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled) and getRetry() (cross-module consumer) remain on the interface. Closes #34 * refactor: decompose FioriRecommendationHandler into focused helpers Extract two helper classes from the 400-line handler: - RecommendationContextBuilder: entity analysis, prediction element discovery, context query building, synthetic key computation, and row assembly - RecommendationResultParser: prediction value type coercion, text path resolution from annotations, description DB lookups, and final recommendations map assembly The handler is now a slim orchestrator (~110 lines) that delegates complex logic to the helpers. Also replace unbounded ConcurrentHashMap.newKeySet() with a bounded Caffeine cache (max 10,000 entries) for the entity negative-result cache. Closes #35 * refactor: accept SDK API clients as constructor parameters Add overloaded constructor to AICoreServiceImpl that accepts DeploymentApi, ConfigurationApi, ResourceGroupApi, and AiCoreService as parameters. The existing convenience constructor delegates to it with default instances. This enables direct injection of mocked clients in unit tests without needing to mock getter methods on the service itself. Closes #36 * docs: document RptInferenceClient as intentional public API Add Javadoc to RptInferenceClient clarifying that it is part of the public API for applications that need direct RPT-1 inference outside the automatic Fiori recommendation flow. Includes usage example. Closes #37 * fix: correct SonarQube project key to com.sap.cds.cds-ai --- .github/actions/scan-with-sonar/action.yml | 2 +- .../recommendation/RptInferenceClient.java | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index 0fd0943..b303984 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -69,7 +69,7 @@ runs: mvn org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.host.url=https://sonar.tools.sap -Dsonar.token="${SONAR_TOKEN}" - -Dsonar.projectKey=cds-ai + -Dsonar.projectKey=com.sap.cds.cds-ai -Dsonar.projectVersion=${{ steps.get-revision.outputs.REVISION }} -Dsonar.qualitygate.wait=true -Dsonar.java.source=17 diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java index 4165699..31a222d 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java @@ -22,6 +22,22 @@ 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.resourceGroupForTenant(tenantId);
+ * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
+ * RptInferenceClient client =
+ *     new RptInferenceClient(service.inferenceClient(rg, deploymentId), 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); From 911da7d0553e04a2c6eb6411e766a6a2cb0cd441 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 28 May 2026 13:02:44 +0200 Subject: [PATCH 32/42] fix: resolve empty JaCoCo aggregate report for SonarQube coverage - Add -am flag to coverage-report generation so dependency modules are in the reactor (required by report-aggregate to resolve classes) - Add -DskipTests to avoid re-running tests - Include individual per-module JaCoCo reports in sonar paths as fallback --- .github/actions/scan-with-sonar/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index b303984..e36855f 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -49,7 +49,7 @@ runs: shell: bash - name: Generate aggregate coverage report - run: mvn verify -ntp -B -pl coverage-report -Dcds.install-node.skip + run: mvn verify -ntp -B -pl coverage-report -am -DskipTests -Dcds.install-node.skip shell: bash - name: Verify JaCoCo reports exist @@ -74,7 +74,7 @@ runs: -Dsonar.qualitygate.wait=true -Dsonar.java.source=17 -Dsonar.exclusions=**/samples/**,**/integration-tests/** - -Dsonar.coverage.jacoco.xmlReportPaths=${{ github.workspace }}/coverage-report/target/site/jacoco-aggregate/jacoco.xml + -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 From 0f008dd3cff6bb2f3c48df837638a64641c6785d Mon Sep 17 00:00:00 2001 From: Marvin L Date: Thu, 28 May 2026 13:44:59 +0200 Subject: [PATCH 33/42] add reuse (#51) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bcc88d5..a4bfbc7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![REUSE status](https://api.reuse.software/badge/github.com/cap-java/cds-feature-ai)](https://api.reuse.software/info/github.com/cap-java/cds-feature-ai) + # SAP Cloud Application Programming Model - AI Plugins for Java ## About this project From 32c9ee9e45e803d9abed649bf047465c73afb5d0 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Thu, 28 May 2026 14:24:49 +0200 Subject: [PATCH 34/42] fix: add assertion to delete_resourceGroup test case (#50) * fix: add assertion to delete_resourceGroup test case SonarQube flagged the test for having no assertions. After deletion, verify the resource group is either removed or no longer in PROVISIONED status. * fix: use assertThatCode for async delete operation The AI Core delete is asynchronous - the resource group remains in PROVISIONED status immediately after deletion. Assert that the delete call itself completes without throwing instead. --- .../com/sap/cds/feature/aicore/itest/ResourceGroupTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 00d334c..87f4fe7 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -124,7 +125,9 @@ void delete_resourceGroup() throws InterruptedException { waitForResourceGroupProvisioned(service, rgId); - service.run(Delete.from("AICore.resourceGroups").where(r -> r.get("resourceGroupId").eq(rgId))); + assertThatCode(() -> + service.run(Delete.from("AICore.resourceGroups").where(r -> r.get("resourceGroupId").eq(rgId))) + ).doesNotThrowAnyException(); createdResourceGroupId = null; // already deleted } From 24106ff2e7586479e0a1cb9bfab4f92fff238cb3 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Fri, 29 May 2026 15:43:39 +0200 Subject: [PATCH 35/42] chore: update repo references from cds-feature-ai to cds-ai (#52) * fix: add assertion to delete_resourceGroup test case SonarQube flagged the test for having no assertions. After deletion, verify the resource group is either removed or no longer in PROVISIONED status. * fix: use assertThatCode for async delete operation The AI Core delete is asynchronous - the resource group remains in PROVISIONED status immediately after deletion. Assert that the delete call itself completes without throwing instead. * chore: update repo references from cds-feature-ai to cds-ai Rename all standalone references to the old repository name 'cds-feature-ai' to the new name 'cds-ai'. This includes: - GitHub URLs (SCM, issues, security policy) - REUSE.toml package metadata - Copyright headers in all source files - License header template in pom.xml Submodule artifact names (cds-feature-ai-core, etc.) are intentionally left unchanged. --- CONTRIBUTING.md | 2 +- README.md | 6 +++--- REUSE.toml | 6 +++--- .../com/sap/cds/feature/aicore/core/AICoreService.java | 2 +- .../feature/aicore/core/AICoreServiceConfiguration.java | 2 +- .../sap/cds/feature/aicore/core/AICoreServiceImpl.java | 2 +- .../sap/cds/feature/aicore/core/AICoreSetupHandler.java | 2 +- .../cds/feature/aicore/core/AbstractAICoreService.java | 2 +- .../cds/feature/aicore/core/MockAICoreServiceImpl.java | 2 +- .../cds/feature/aicore/core/MockAICoreSetupHandler.java | 2 +- .../sap/cds/feature/aicore/core/ModelDeploymentSpec.java | 2 +- .../core/handler/AICoreApplicationServiceHandler.java | 2 +- .../feature/aicore/core/handler/AbstractCrudHandler.java | 2 +- .../cds/feature/aicore/core/handler/ActionHandler.java | 2 +- .../feature/aicore/core/handler/ConfigurationHandler.java | 2 +- .../feature/aicore/core/handler/DeploymentHandler.java | 2 +- .../feature/aicore/core/handler/MockEntityHandler.java | 2 +- .../feature/aicore/core/handler/ResourceGroupHandler.java | 2 +- .../cds/feature/aicore/core/AICoreServiceImplTest.java | 2 +- .../cds/feature/aicore/core/AICoreSetupHandlerTest.java | 2 +- .../aicore/core/handler/ConfigurationHandlerTest.java | 2 +- .../aicore/core/handler/DeploymentHandlerTest.java | 2 +- .../aicore/core/handler/ResourceGroupHandlerTest.java | 2 +- .../recommendation/FioriRecommendationHandler.java | 2 +- .../feature/recommendation/MockRecommendationClient.java | 2 +- .../cds/feature/recommendation/RecommendationClient.java | 2 +- .../recommendation/RecommendationClientResolver.java | 2 +- .../recommendation/RecommendationConfiguration.java | 2 +- .../recommendation/RecommendationContextBuilder.java | 2 +- .../recommendation/RecommendationResultParser.java | 2 +- .../cds/feature/recommendation/RptInferenceClient.java | 2 +- .../com/sap/cds/feature/recommendation/RptModelSpec.java | 2 +- .../recommendation/FioriRecommendationHandlerTest.java | 2 +- .../recommendation/RecommendationConfigurationTest.java | 2 +- .../com/sap/cds/feature/aicore/itest/mt/Application.java | 2 +- .../sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java | 2 +- .../feature/aicore/itest/mt/SubscribeUnsubscribeTest.java | 2 +- .../cds/feature/aicore/itest/mt/TenantIsolationTest.java | 2 +- .../aicore/itest/mt/utils/SubscriptionEndpointClient.java | 2 +- .../src/main/java/com/sap/cds/feature/Application.java | 2 +- .../sap/cds/feature/aicore/itest/AICoreServiceTest.java | 2 +- .../java/com/sap/cds/feature/aicore/itest/ActionTest.java | 2 +- .../aicore/itest/ApplicationServiceDelegationTest.java | 2 +- .../sap/cds/feature/aicore/itest/BaseIntegrationTest.java | 2 +- .../sap/cds/feature/aicore/itest/ConfigurationTest.java | 2 +- .../com/sap/cds/feature/aicore/itest/DeploymentTest.java | 2 +- .../sap/cds/feature/aicore/itest/MultiTenancyTest.java | 2 +- .../java/com/sap/cds/feature/aicore/itest/ODataTest.java | 2 +- .../aicore/itest/ResourceGroupCleanupExtension.java | 2 +- .../sap/cds/feature/aicore/itest/ResourceGroupTest.java | 2 +- .../itest/NonStandardKeyRecommendationTest.java | 2 +- .../feature/recommendation/itest/RecommendationTest.java | 2 +- pom.xml | 8 ++++---- 53 files changed, 60 insertions(+), 60 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9766894..8278ed7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ 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-feature-ai/issues) or by contacting one of the project maintainers listed in the repository. +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 diff --git a/README.md b/README.md index a4bfbc7..e1334a8 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,11 @@ 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-feature-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). +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-feature-ai/security/policy) on how to report it. Please do not create GitHub issues for security-related doubts or problems. +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 @@ -78,4 +78,4 @@ We as members, contributors, and leaders pledge to make participation in our com ## Licensing -Copyright 2026 SAP SE or an SAP affiliate company and cds-feature-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. +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 index 576b23e..e0fac8d 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,11 +1,11 @@ version = 1 -SPDX-PackageName = "cds-feature-ai" +SPDX-PackageName = "cds-ai" SPDX-PackageSupplier = "ospo@sap.com" -SPDX-PackageDownloadLocation = "https://github.com/cap-java/cds-feature-ai" +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-feature-ai contributors" +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/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java index 91a3f3d..7bca945 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index eca6c25..46a688c 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index 207de4b..d0bd3f1 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index fddd340..e4fd983 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index 5919415..77cc2a6 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index ee08623..7c775b4 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index 4d5730c..fe152fd 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java index 5ca2e1f..0ca8ed2 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index cc92b34..1b340cd 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 47f3858..402ab9b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 611cd23..25a019c 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 9fd641f..6f7f2ff 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 931c2a4..2557f2c 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 229a3c7..72a86fc 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index aa61f7f..4f6ccb4 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 0593c29..aab97da 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index 95bbba2..f475e5e 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core; 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 index 0aef6a9..03e0579 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 00fe4a0..fb4211b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 925737d..2debb0d 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.core.handler; 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 index 2af07ba..4771c9d 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; 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 index 14e2135..6560726 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java index fbb2858..82213d3 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java index 5592678..76b9407 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; 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 index b2762eb..427d8a4 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; 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 index deb3d7c..a158123 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; 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 index 5685ed0..df412c7 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java index 31a222d..83247fc 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java index 4738fd9..5b70b25 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; 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 index 7e46d4b..6ab17fa 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; 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 index b3df6d1..9926497 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation; 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 index 03a563e..1897d22 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest.mt; 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 index 645d9b0..d303bf5 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest.mt; 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 index ce9deb9..7cb2680 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest.mt; 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 index 2db4b7e..989ecfc 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest.mt; 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 index 51edfe5..d87d599 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest.mt.utils; 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 index 8024abe..b839afa 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature; 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 index 78e91e0..e7f335a 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index caf4a4c..21c5b2d 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index 305edd9..c8f1296 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index 51abd19..6eb8168 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index 9516dd1..ff45c9e 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index 7dae971..77f5502 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index 35a7226..15f1ff0 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index 305bb47..c6919f5 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index dc251d1..61651d6 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index 87f4fe7..2d32455 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.aicore.itest; 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 index 270b699..558babb 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation.itest; 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 index eff26e4..cbbd1d8 100644 --- 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 @@ -1,5 +1,5 @@ /* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ package com.sap.cds.feature.recommendation.itest; diff --git a/pom.xml b/pom.xml index 1a4408b..19fbab2 100644 --- a/pom.xml +++ b/pom.xml @@ -31,9 +31,9 @@ - scm:git:git@github.com:cap-java/cds-feature-ai.git - scm:git:git@github.com:cap-java/cds-feature-ai.git - https://github.com/cap-java/cds-feature-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 @@ -394,7 +394,7 @@ /* - * © $YEAR SAP SE or an SAP affiliate company and cds-feature-ai contributors. + * © $YEAR SAP SE or an SAP affiliate company and cds-ai contributors. */ From 5a3ba6de616685556643b161086c47cdad6dbb43 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Fri, 29 May 2026 15:47:23 +0200 Subject: [PATCH 36/42] Update Reuse Badge (#53) Signed-off-by: Marvin L --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e1334a8..0dc5f95 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![REUSE status](https://api.reuse.software/badge/github.com/cap-java/cds-feature-ai)](https://api.reuse.software/info/github.com/cap-java/cds-feature-ai) +[![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 From 40e1f3d1517adac603824a202740d8b8d015e3c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:50:17 +0200 Subject: [PATCH 37/42] chore(deps): bump the minor-patch group across 1 directory with 2 updates (#48) Bumps the minor-patch group with 2 updates in the / directory: [org.junit:junit-bom](https://github.com/junit-team/junit-framework) and [com.diffplug.spotless:spotless-maven-plugin](https://github.com/diffplug/spotless). Updates `org.junit:junit-bom` from 6.0.3 to 6.1.0 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.0.3...r6.1.0) Updates `com.diffplug.spotless:spotless-maven-plugin` from 3.5.0 to 3.5.1 - [Release notes](https://github.com/diffplug/spotless/releases) - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/maven/3.5.0...maven/3.5.1) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-maven-plugin dependency-version: 3.5.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marvin L --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 19fbab2..1a39d1a 100644 --- a/pom.xml +++ b/pom.xml @@ -71,7 +71,7 @@ 0.8.14 - 6.0.3 + 6.1.0 5.23.0 @@ -386,7 +386,7 @@ com.diffplug.spotless spotless-maven-plugin - 3.5.0 + 3.5.1 From 060c4ff7fd08454a6afffc2787769004077d0f2b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:58:30 +0200 Subject: [PATCH 38/42] chore(deps): bump actions/stale in the minor-patch group (#47) Bumps the minor-patch group with 1 update: [actions/stale](https://github.com/actions/stale). Updates `actions/stale` from 10.2.0 to 10.3.0 - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 10.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marvin L --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4feaa66..3813641 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -14,7 +14,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10 with: close-issue-message: "This issue has been automatically closed due to 2 weeks of inactivity. If you believe this was a mistake, please reopen or comment to continue the discussion." days-before-stale: -1 From edb800fb562c953c4d9e47d0d502ba8a8d0f91c4 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Tue, 2 Jun 2026 11:54:20 +0200 Subject: [PATCH 39/42] chore: use shared org-level GitHub Actions and workflows (#55) Replace local composite actions (build, deploy-release, scan-with-codeql, scan-with-blackduck) and reusable workflows (issue, stale, prevent-issue-labeling) with their shared equivalents from cap-java/.github. Refactor cf-bind to delegate CF CLI install and login to the shared cf-login action. Closes #54 --- .github/actions/build/action.yml | 33 -------- .github/actions/cf-bind/action.yml | 36 ++------- .github/actions/deploy-release/action.yml | 71 ----------------- .../actions/scan-with-blackduck/action.yml | 79 ------------------- .github/actions/scan-with-codeql/action.yml | 52 ------------ .github/workflows/issue.yml | 19 +---- .github/workflows/main.yml | 4 +- .github/workflows/pipeline.yml | 3 +- .github/workflows/prevent-issue-labeling.yml | 14 +--- .github/workflows/release.yml | 16 ++-- .github/workflows/stale.yml | 12 +-- 11 files changed, 25 insertions(+), 314 deletions(-) delete mode 100644 .github/actions/build/action.yml delete mode 100644 .github/actions/deploy-release/action.yml delete mode 100644 .github/actions/scan-with-blackduck/action.yml delete mode 100644 .github/actions/scan-with-codeql/action.yml diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml deleted file mode 100644 index b62d932..0000000 --- a/.github/actions/build/action.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Maven Build -description: Builds a Maven project. - -inputs: - java-version: - description: The Java version the build will run with. - required: true - maven-version: - description: The Maven version the build will 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: Set up Maven ${{ inputs.maven-version }} - uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 - with: - maven-version: ${{ inputs.maven-version }} - - - name: Install @sap/cds-dk - run: npm i -g @sap/cds-dk@9.9.1 - shell: bash - - - name: Maven Build - run: mvn clean install -DskipTests -B -ntp -Dcds.install-node.skip -P '!with-integration-tests' - shell: bash diff --git a/.github/actions/cf-bind/action.yml b/.github/actions/cf-bind/action.yml index e88154e..06c12c8 100644 --- a/.github/actions/cf-bind/action.yml +++ b/.github/actions/cf-bind/action.yml @@ -21,36 +21,14 @@ inputs: runs: using: composite steps: - - name: Install CF CLI - shell: bash - env: - CF_CLI_VERSION: '8.18.3' - run: | - wget -q "https://packages.cloudfoundry.org/stable?release=linux64-binary&version=${CF_CLI_VERSION}&source=github-rel" -O cf-cli.tar.gz - tar -xzf cf-cli.tar.gz - sudo mv cf8 /usr/local/bin/cf - cf --version - - name: CF Login - shell: bash - env: - CF_USERNAME: ${{ inputs.cf-username }} - CF_PASSWORD: ${{ inputs.cf-password }} - CF_API: ${{ inputs.cf-api }} - CF_ORG: ${{ inputs.cf-org }} - CF_SPACE: ${{ inputs.cf-space }} - run: | - for i in {1..5}; do - cf api "$CF_API" && \ - cf auth && \ - cf target -o "$CF_ORG" -s "$CF_SPACE" && break - if [ "$i" -eq 5 ]; then - echo "cf login failed after 5 attempts." - exit 1 - fi - echo "cf login failed, retrying ($i/5)..." - sleep 10 - done + 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 diff --git a/.github/actions/deploy-release/action.yml b/.github/actions/deploy-release/action.yml deleted file mode 100644 index 5f38a83..0000000 --- a/.github/actions/deploy-release/action.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Deploy Release to Maven Central -description: Deploys released artifacts to Maven Central repository. - -inputs: - user: - description: The user used for the upload (technical user for maven central upload) - required: true - password: - description: The password used for the upload (technical user for maven central upload) - required: true - pgp-pub-key: - description: The public pgp key ID - required: true - pgp-private-key: - description: The private pgp key - required: true - pgp-passphrase: - description: The passphrase for pgp - required: true - revision: - description: The revision of cds-feature-ai - required: true - maven-version: - description: The Maven version the build will run with. - required: true - -runs: - using: composite - steps: - - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - distribution: sapmachine - java-version: '17' - cache: maven - server-id: central - server-username: MAVEN_CENTRAL_USER - server-password: MAVEN_CENTRAL_PASSWORD - - - name: Set up Maven ${{ inputs.maven-version }} - uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 - with: - maven-version: ${{ inputs.maven-version }} - - - name: Install @sap/cds-dk - run: npm i -g @sap/cds-dk@9.9.1 - shell: bash - - - name: Import GPG Key - run: | - echo "$PGP_PRIVATE_KEY" | gpg --batch --passphrase "$PASSPHRASE" --import - shell: bash - env: - PGP_PRIVATE_KEY: ${{ inputs.pgp-private-key }} - PASSPHRASE: ${{ inputs.pgp-passphrase }} - - - name: Deploy to Maven Central - run: > - mvn -B -ntp --show-version - -Dmaven.install.skip=true - -Dmaven.test.skip=true - -Dcds.install-node.skip - -Dgpg.passphrase="$GPG_PASSPHRASE" - -Dgpg.keyname="$GPG_PUB_KEY" - clean deploy -P deploy-release,'!with-integration-tests' - shell: bash - env: - MAVEN_CENTRAL_USER: ${{ inputs.user }} - MAVEN_CENTRAL_PASSWORD: ${{ inputs.password }} - GPG_PASSPHRASE: ${{ inputs.pgp-passphrase }} - GPG_PUB_KEY: ${{ inputs.pgp-pub-key }} diff --git a/.github/actions/scan-with-blackduck/action.yml b/.github/actions/scan-with-blackduck/action.yml deleted file mode 100644 index f8e3292..0000000 --- a/.github/actions/scan-with-blackduck/action.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Scan with BlackDuck -description: Scans the project with BlackDuck - -inputs: - blackduck_token: - description: The token to use for BlackDuck authentication - required: true - github_token: - description: The token to use for GitHub authentication - required: true - java-version: - description: The version of Java to use - default: "17" - required: false - maven-version: - description: The Maven version the build shall run with. - required: true - version: - description: The project version to report to Black Duck (e.g. release tag). If empty, falls back to the Maven `revision` reduced to major-minor. - required: false - default: "" - scan_mode: - description: The scan mode to use (FULL uploads a report to the Black Duck server; RAPID is a fast policy gate without server upload). - default: "FULL" - required: false - -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: Resolve Project Version - id: resolve-version - env: - VERSION_INPUT: ${{ inputs.version }} - run: | - if [ -n "$VERSION_INPUT" ]; then - VERSION="$VERSION_INPUT" - else - REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout) - VERSION=$(echo "$REVISION" | cut -d. -f1,2) - fi - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "Resolved BlackDuck project version: $VERSION" - shell: bash - - - name: BlackDuck Security Scan - uses: blackduck-inc/black-duck-security-scan@659a0742e793a093377fab3117b0d90f23b04bfa # v2.9.0 - with: - blackducksca_url: https://sap.blackducksoftware.com/ - blackducksca_token: ${{ inputs.blackduck_token }} - blackducksca_scan_full: ${{ inputs.scan_mode == 'FULL' }} - github_token: ${{ inputs.github_token }} - detect_args: > - --detect.project.name=com.sap.cds.cds-ai - --detect.project.version.name=${{ steps.resolve-version.outputs.VERSION }} - --detect.project.user.groups=CDSJAVA-OPEN-SOURCE - --detect.included.detector.types=MAVEN - --detect.excluded.directories=**/*test*,**/samples/** - --detect.maven.included.modules=cds-feature-ai-core,cds-feature-recommendations,cds-starter-ai - --detect.maven.excluded.scopes=test,provided - --detect.tools=DETECTOR,BINARY_SCAN - --detect.timeout=6000 - --detect.risk.report.pdf=false - --detect.policy.check.fail.on.severities=NONE - --detect.force.success.on.skip=true - --blackduck.signature.scanner.memory=4096 - --blackduck.trust.cert=true - --logging.level.detect=INFO diff --git a/.github/actions/scan-with-codeql/action.yml b/.github/actions/scan-with-codeql/action.yml deleted file mode 100644 index 5cbd337..0000000 --- a/.github/actions/scan-with-codeql/action.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: CodeQL Analysis -description: Runs CodeQL security analysis on the project. - -inputs: - java-version: - description: The Java version to use for the build. - required: true - maven-version: - description: The Maven version to use for the build. - required: true - language: - description: The CodeQL language to analyze (java-kotlin or actions). - required: true - -runs: - using: composite - steps: - - name: Set up Java ${{ inputs.java-version }} - if: inputs.language == 'java-kotlin' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: ${{ inputs.java-version }} - distribution: sapmachine - cache: maven - - - name: Set up Maven ${{ inputs.maven-version }} - if: inputs.language == 'java-kotlin' - uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5 - with: - maven-version: ${{ inputs.maven-version }} - - - name: Initialize CodeQL - uses: github/codeql-action/init@ed410739ba306e4ebe5e123421a6bd694e494a2b # v4 - with: - languages: ${{ inputs.language }} - build-mode: ${{ inputs.language == 'java-kotlin' && 'manual' || 'none' }} - queries: security-extended - - - name: Install @sap/cds-dk - if: inputs.language == 'java-kotlin' - run: npm i -g @sap/cds-dk@9.9.1 - shell: bash - - - name: Build Java code - if: inputs.language == 'java-kotlin' - run: mvn clean compile -B -ntp -Dcds.install-node.skip - shell: bash - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ed410739ba306e4ebe5e123421a6bd694e494a2b # v4 - with: - category: "/language:${{ inputs.language }}" diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index 6aa729f..5d37720 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -9,23 +9,6 @@ on: jobs: label_issues: - runs-on: ubuntu-latest + uses: cap-java/.github/.github/workflows/issue.yml@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main permissions: issues: write - steps: - - run: gh issue edit "$NUMBER" --add-label "$LABELS" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - NUMBER: ${{ github.event.issue.number }} - LABELS: New - - - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - script: | - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: `👋 Hello @${context.payload.issue.user.login}, thank you for submitting this issue. Our team is reviewing your report and will follow up with you as soon as possible.` - }) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 738ce1b..923d44d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,11 +20,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Scan With Black Duck - uses: ./.github/actions/scan-with-blackduck + 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: diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 361896b..4fc361f 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -210,8 +210,9 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: CodeQL Analysis - uses: ./.github/actions/scan-with-codeql + 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/prevent-issue-labeling.yml b/.github/workflows/prevent-issue-labeling.yml index 6c35030..4c99391 100644 --- a/.github/workflows/prevent-issue-labeling.yml +++ b/.github/workflows/prevent-issue-labeling.yml @@ -8,18 +8,6 @@ on: jobs: remove_new_label: - runs-on: ubuntu-latest + uses: cap-java/.github/.github/workflows/prevent-issue-labeling.yml@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main permissions: issues: write - steps: - - name: Remove "New" label if applied by non-bot user - if: > - contains(github.event.issue.labels.*.name, 'New') && - github.event.label.name == 'New' && - github.event.sender.login != 'github-actions[bot]' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - gh issue edit "$ISSUE_NUMBER" --remove-label "New" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c468e9..6857eb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,11 +71,13 @@ jobs: ref: ${{ github.event.release.tag_name }} - name: Scan With Black Duck - uses: ./.github/actions/scan-with-blackduck + 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: @@ -92,10 +94,11 @@ jobs: ref: ${{ github.event.release.tag_name }} - name: Build - uses: ./.github/actions/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 @@ -112,12 +115,13 @@ jobs: ref: ${{ github.event.release.tag_name }} - name: Deploy - uses: ./.github/actions/deploy-release + uses: cap-java/.github/actions/deploy-release@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main with: user: ${{ secrets.CENTRAL_REPOSITORY_USER }} password: ${{ secrets.CENTRAL_REPOSITORY_PASS }} - pgp-pub-key: ${{ secrets.PGP_PUBKEY_ID }} - pgp-private-key: ${{ secrets.PGP_PRIVATE_KEY }} - pgp-passphrase: ${{ secrets.PGP_PASSPHRASE }} + 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 index 3813641..8bbf3c1 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,18 +8,8 @@ on: jobs: stale: - runs-on: ubuntu-latest + uses: cap-java/.github/.github/workflows/stale.yml@296573b55e906f5c77a1855bcfe4285cbbc5cac4 # main permissions: actions: write issues: write pull-requests: write - steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10 - with: - close-issue-message: "This issue has been automatically closed due to 2 weeks of inactivity. If you believe this was a mistake, please reopen or comment to continue the discussion." - days-before-stale: -1 - days-before-issue-close: 14 - days-before-pr-close: -1 - stale-issue-label: "author action" - remove-issue-stale-when-updated: true - labels-to-remove-when-unstale: "author action" From e24fdb986b15304b2e10a06c69fb2b4b2acbf59e Mon Sep 17 00:00:00 2001 From: Marvin L Date: Wed, 3 Jun 2026 08:13:54 +0200 Subject: [PATCH 40/42] chore: code review cleanup before review (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: code review cleanup before senior review - fix(AICoreServiceImpl): move deploymentLocks from a Caffeine cache to ConcurrentHashMap so two threads asking for the same key always synchronize on the same monitor; eviction could otherwise cause duplicate AI Core deployments. - fix(AICoreServiceImpl): wrap deploymentApi.get on cached deployment in try/catch so a 404 (deployment deleted out-of-band) invalidates the stale cache entry instead of failing forever. - refactor(DeploymentHandler): extract a shared applyTo + DeploymentValues record so the two toDeployments overloads share the field-set logic; remaining duplication is the unavoidable getter-chain on two SDK types with no common interface. - docs: README updates - replace nonexistent AIClient / AICoreClient / fetchPredictions / rpt1DeploymentId references with the real API, align recommendations contextRowLimit config key with the code. - docs: full Javadoc on AICoreService, ModelDeploymentSpec, AICoreServiceConfiguration, and class-level Javadoc on AICoreServiceImpl. - chore: add @Override annotations across AICoreServiceImpl and MockAICoreServiceImpl; promote getRuntime() to AbstractAICoreService. - chore(cds-starter-ai): keep packaging=jar (matches CAP starter convention) but document the empty-jar starter pattern explicitly. - docs(RecommendationResultParser): explain the placeholder 0.5 score emitted to Fiori Elements until the AI SDK exposes prediction_proba. - test: new AICoreServiceImplDeploymentIdTest covering cache-hit, stale-cache 404 invalidation, existing-deployment reuse, second-call caching, and create-when-config-exists happy paths. - chore: mvn spotless:apply (Google Java Format + sort-pom). * build: make mvn install hermetic via npm ci (no global @sap/cds-dk) Introduce a package.json + package-lock.json at cds-feature-ai-core root pinning @sap/cds-dk@9.9.1, and bind a new cds-maven-plugin 'cds.npm-ci' execution that runs 'npm ci' before the cds.build goal. A fresh checkout can now run 'mvn install' without any prerequisite beyond Maven + JDK; cds-maven-plugin's bundled Node downloads @sap/cds-dk into a local node_modules at module root, outside the cds source tree (so 'cds build --src ./ --dest ../../../../../../gen/srv' no longer self-copies node_modules). Drop -Dcds.install-node.skip from every Maven invocation in CI (.github/workflows/pipeline.yml, .github/actions/integration-tests, .github/actions/scan-with-sonar) since the bundled Node is now the canonical execution environment for the cds CLI inside Maven. Drop the standalone 'npm i -g @sap/cds-dk' steps from the 'tests' and 'local-mtx-tests' jobs (Maven-only paths). The cf-bind composite action keeps its global cds-dk install because cds bind --exec is invoked as a shell command in subsequent steps. * fix: narrow stale-cache catch to 404 only; use deploymentCacheKey helper in tests Address two of three review comments from the PR bot on PR #57: 1. (AICoreServiceImpl#deploymentId) The previous catch on OpenApiRequestException swallowed all OpenApi failures — including transient 5xx and network errors — and unconditionally invalidated the cache entry. That would silently discard a still-valid cached deployment and trigger a duplicate creation. Narrow the catch to only treat HTTP 404 as the 'deployment deleted out-of-band' signal; propagate everything else so the caller's retry/backoff policy handles it. 2. (AICoreServiceImplDeploymentIdTest) The tests seeded the cache with the raw string 'RG + "::" + CONFIG_NAME', duplicating the cache-key format with the production code. A future change to the deploymentCacheKey() format would silently make the tests pass for the wrong reason. Promote deploymentCacheKey() from private to package-private and have the tests derive the key through it. 3. Add a regression test (cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry) that exercises the narrowed catch: a 503 from deploymentApi.get must propagate AND leave the cache entry intact. * build: add hermetic npm ci for cds-feature-recommendations The previous commit (67da453) made cds-feature-ai-core hermetic by binding 'npm ci' to its Maven build, but cds-feature-recommendations also runs 'cds compile' (via cds.compile execution) and was still relying on a globally installed @sap/cds-dk that we removed from CI. Mirror the same pattern: introduce a package.json + package-lock.json at the cds-feature-recommendations module root pinning @sap/cds-dk@9.9.1, and add 'cds.install-node' + 'cds.npm-ci' executions before 'cds.compile'. --- .github/actions/integration-tests/action.yml | 4 +- .github/actions/scan-with-sonar/action.yml | 6 +- .github/workflows/pipeline.yml | 12 +- cds-feature-ai-core/README.md | 19 +- cds-feature-ai-core/package-lock.json | 1861 +++++++++++++++++ cds-feature-ai-core/package.json | 9 + cds-feature-ai-core/pom.xml | 9 + .../feature/aicore/core/AICoreService.java | 65 + .../core/AICoreServiceConfiguration.java | 10 + .../aicore/core/AICoreServiceImpl.java | 74 +- .../aicore/core/AbstractAICoreService.java | 5 + .../aicore/core/MockAICoreServiceImpl.java | 6 + .../aicore/core/ModelDeploymentSpec.java | 18 + .../aicore/core/handler/ActionHandler.java | 3 +- .../core/handler/ConfigurationHandler.java | 9 +- .../core/handler/DeploymentHandler.java | 129 +- .../core/handler/ResourceGroupHandler.java | 6 +- .../AICoreServiceImplDeploymentIdTest.java | 225 ++ .../aicore/core/AICoreServiceImplTest.java | 44 +- cds-feature-recommendations/README.md | 2 +- cds-feature-recommendations/package-lock.json | 1861 +++++++++++++++++ cds-feature-recommendations/package.json | 9 + cds-feature-recommendations/pom.xml | 15 + .../RecommendationResultParser.java | 4 + cds-starter-ai/pom.xml | 9 +- pom.xml | 12 +- 26 files changed, 4311 insertions(+), 115 deletions(-) create mode 100644 cds-feature-ai-core/package-lock.json create mode 100644 cds-feature-ai-core/package.json create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java create mode 100644 cds-feature-recommendations/package-lock.json create mode 100644 cds-feature-recommendations/package.json diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index fd01b3d..49a4060 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -25,12 +25,12 @@ runs: 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 -Dcds.install-node.skip + 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 -Dcds.install-node.skip + run: cds bind --exec -- mvn clean verify -ntp -B -f pom.xml working-directory: integration-tests shell: bash diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml index e36855f..c01bf24 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -38,18 +38,18 @@ runs: 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 -Dcds.install-node.skip + 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 -Dcds.install-node.skip + run: cds bind --exec -- mvn clean verify install -ntp -B working-directory: integration-tests shell: bash - name: Generate aggregate coverage report - run: mvn verify -ntp -B -pl coverage-report -am -DskipTests -Dcds.install-node.skip + run: mvn verify -ntp -B -pl coverage-report -am -DskipTests shell: bash - name: Verify JaCoCo reports exist diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 4fc361f..2e7b238 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -33,12 +33,8 @@ jobs: with: maven-version: ${{ env.MAVEN_VERSION }} - - name: Install @sap/cds-dk - run: npm i -g @sap/cds-dk@9.9.1 - shell: bash - - name: Run Tests - run: mvn test -ntp -B -P '!with-integration-tests' -Dcds.install-node.skip + run: mvn test -ntp -B -P '!with-integration-tests' integration-tests: name: Integration Tests (Java ${{ matrix.java-version }}) @@ -156,12 +152,8 @@ jobs: with: maven-version: ${{ env.MAVEN_VERSION }} - - name: Install @sap/cds-dk - run: npm i -g @sap/cds-dk@9.9.1 - shell: bash - - name: Run Local MTX Tests - run: mvn clean verify -ntp -B -pl integration-tests/mtx-local/srv -am -P mtx-integration-tests -Dcds.install-node.skip + run: mvn clean verify -ntp -B -pl integration-tests/mtx-local/srv -am -P mtx-integration-tests sonarqube-scan: name: SonarQube Scan diff --git a/cds-feature-ai-core/README.md b/cds-feature-ai-core/README.md index d8229f7..c447859 100644 --- a/cds-feature-ai-core/README.md +++ b/cds-feature-ai-core/README.md @@ -6,8 +6,8 @@ Bridges CAP Java applications to [SAP AI Core](https://help.sap.com/docs/sap-ai- - **`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 -- **RPT-1 Deployment Management** - Auto-creates configurations and deployments for the SAP-RPT-1 model -- **Prediction Client** - `AIClient` interface for calling the RPT-1 prediction API with retry and backoff +- **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 @@ -67,8 +67,8 @@ The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs // Get the resource group ID for a CDS tenant String rgId = aiCoreService.resourceGroupForTenant(tenantId); -// Get (or auto-create) the RPT-1 deployment ID for a resource group -String deploymentId = aiCoreService.rpt1DeploymentId(resourceGroupId); +// 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 @@ -91,11 +91,16 @@ AICoreService aiCore = runtime.getServiceCatalog() // Use for entity operations (AICoreService extends CqnService) Result rgs = aiCore.run(Select.from("AICore.resourceGroups")); -// Use the prediction client -AIClient client = new AICoreClient(aiCore, runtime); -List predictions = client.fetchPredictions(rows, targetColumns, indexColumn); +// Resolve a deployment and obtain a configured ApiClient for it +String resourceGroupId = aiCore.resourceGroupForTenant(tenantId); +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) 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 index 45109b8..c49b701 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -61,6 +61,15 @@ install-node + + cds.npm-ci + + npm + + + ci + + cds.build diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java index 7bca945..6222911 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java @@ -7,20 +7,85 @@ import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import io.github.resilience4j.retry.Retry; +/** + * 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: + * + *

    + *
  • Resolve the resource group ID for a CDS tenant ({@link #resourceGroupForTenant(String)}), + * creating it on-demand when multi-tenancy is enabled. + *
  • Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link + * #deploymentId(String, ModelDeploymentSpec)}). + *
  • Build an {@link ApiClient} preconfigured for inference against a specific deployment + * ({@link #inferenceClient(String, String)}). + *
  • Expose a shared retry/backoff policy ({@link #getRetry()}) for downstream callers that want + * consistent transient-error handling. + *
+ * + *

Two implementations are provided: {@link AICoreServiceImpl} (when an SAP AI Core service + * binding is detected) and {@link MockAICoreServiceImpl} (in-memory fallback for local + * development). + */ 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 given CDS tenant. + * + *

When multi-tenancy is disabled the configured {@code cds.requires.AICore.resourceGroup} is + * returned for every tenant. 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. + * + * @param tenantId the CDS tenant identifier; may be {@code null} when multi-tenancy is disabled + * @return the AI Core resource group ID + */ String resourceGroupForTenant(String tenantId); + /** + * 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); + /** Returns whether multi-tenancy is enabled for this service. */ boolean isMultiTenancyEnabled(); + /** + * Returns the shared {@link Retry} used internally for transient AI Core errors. Exposed so + * downstream inference clients can reuse the same backoff policy. + */ Retry getRetry(); } 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 index 46a688c..4fd552f 100644 --- 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 @@ -20,6 +20,16 @@ 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); 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 index d0bd3f1..db39072 100644 --- 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 @@ -34,9 +34,22 @@ 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); @@ -52,7 +65,15 @@ public class AICoreServiceImpl extends AbstractAICoreService { private final Cache tenantResourceGroupCache; private final Cache resourceGroupDeploymentCache; - private final Cache deploymentLocks; + + /** + * 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; @@ -88,7 +109,6 @@ public AICoreServiceImpl( this.retry = buildRetry(maxRetries, initialDelayMs); this.tenantResourceGroupCache = newCache(); this.resourceGroupDeploymentCache = newCache(); - this.deploymentLocks = newCache(); this.deploymentApi = deploymentApi; this.configurationApi = configurationApi; this.resourceGroupApi = resourceGroupApi; @@ -114,14 +134,31 @@ public String resourceGroupForTenant(String tenantId) { @Override public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { String cacheKey = deploymentCacheKey(resourceGroupId, spec); - Object lock = deploymentLocks.get(cacheKey, k -> new Object()); + Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object()); synchronized (lock) { String cached = resourceGroupDeploymentCache.getIfPresent(cacheKey); if (cached != null) { - var current = deploymentApi.get(resourceGroupId, cached); - if (AiDeploymentStatus.RUNNING.equals(current.getStatus()) - || AiDeploymentStatus.PENDING.equals(current.getStatus())) { - return cached; + 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); } @@ -153,6 +190,7 @@ public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { return ApiClient.create(destination); } + @Override public boolean isMultiTenancyEnabled() { return multiTenancyEnabled; } @@ -162,26 +200,26 @@ 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 CdsRuntime getRuntime() { - return runtime; - } - public DeploymentApi getDeploymentApi() { return deploymentApi; } @@ -194,6 +232,7 @@ public ResourceGroupApi getResourceGroupApi() { return resourceGroupApi; } + @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { return (String) keys.get("resourceGroup_resourceGroupId"); @@ -206,6 +245,7 @@ public String resolveResourceGroupFromKeys(Map keys) { return resourceGroupForTenant(tenantId); } + @Override public void clearTenantCache(String tenantId) { String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); if (resourceGroupId != null) { @@ -214,14 +254,16 @@ public void clearTenantCache(String tenantId) { .asMap() .keySet() .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); - deploymentLocks - .asMap() - .keySet() - .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); } } - private static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { + /** + * 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(); } 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 index 77cc2a6..435e74f 100644 --- 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 @@ -18,6 +18,11 @@ 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 configured default resource group identifier. */ public abstract String getDefaultResourceGroup(); 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 index 7c775b4..1b385bb 100644 --- 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 @@ -67,22 +67,27 @@ 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) { @@ -93,6 +98,7 @@ public void clearTenantCache(String tenantId) { } } + @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { return (String) keys.get("resourceGroup_resourceGroupId"); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java index 0ca8ed2..4df6194 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java @@ -8,6 +8,24 @@ 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, 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 index 25a019c..d255a90 100644 --- 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 @@ -3,13 +3,12 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; - 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.core.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; 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 index 6f7f2ff..98d9728 100644 --- 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 @@ -86,16 +86,11 @@ public void onCreate(CdsCreateEventContext context, List entries .executableId(entry.getExecutableId()) .scenarioId(entry.getScenarioId()); - Collection paramBindings = - entry.getParameterBindings(); + Collection paramBindings = entry.getParameterBindings(); if (paramBindings != null) { List sdkBindings = paramBindings.stream() - .map( - p -> - AiParameterArgumentBinding.create() - .key(p.getKey()) - .value(p.getValue())) + .map(p -> AiParameterArgumentBinding.create().key(p.getKey()).value(p.getValue())) .toList(); request.parameterBindings(sdkBindings); } 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 index 2557f2c..750d140 100644 --- 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 @@ -28,6 +28,7 @@ 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; @@ -140,52 +141,102 @@ public void onDelete(CdsDeleteEventContext context) { context.setResult(List.of()); } - // CPD-OFF - SDK types AiDeploymentResponseWithDetails and AiDeployment share no common interface + // 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) { - Deployments data = Deployments.create(); - data.setId(d.getId()); - data.setDeploymentUrl(d.getDeploymentUrl()); - data.setConfigurationId(d.getConfigurationId()); - data.setConfigurationName(d.getConfigurationName()); - data.setExecutableId(d.getExecutableId()); - data.setScenarioId(d.getScenarioId()); - data.setStatus(d.getStatus().getValue()); - data.setStatusMessage(d.getStatusMessage()); - data.setTargetStatus(d.getTargetStatus().getValue()); - data.setLastOperation(d.getLastOperation() != null ? d.getLastOperation().getValue() : null); - data.setLatestRunningConfigurationId(d.getLatestRunningConfigurationId()); - data.setTtl(d.getTtl()); - data.put(Deployments.CREATED_AT, d.getCreatedAt()); - data.put(Deployments.MODIFIED_AT, d.getModifiedAt()); - data.put(Deployments.SUBMISSION_TIME, d.getSubmissionTime()); - data.put(Deployments.START_TIME, d.getStartTime()); - data.put(Deployments.COMPLETION_TIME, d.getCompletionTime()); - data.setResourceGroup(ResourceGroups.create(resourceGroupId)); - return data; + 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(d.getId()); - data.setDeploymentUrl(d.getDeploymentUrl()); - data.setConfigurationId(d.getConfigurationId()); - data.setConfigurationName(d.getConfigurationName()); - data.setExecutableId(d.getExecutableId()); - data.setScenarioId(d.getScenarioId()); - data.setStatus(d.getStatus().getValue()); - data.setStatusMessage(d.getStatusMessage()); - data.setTargetStatus(d.getTargetStatus().getValue()); - data.setLastOperation(d.getLastOperation() != null ? d.getLastOperation().getValue() : null); - data.setLatestRunningConfigurationId(d.getLatestRunningConfigurationId()); - data.setTtl(d.getTtl()); - data.put(Deployments.CREATED_AT, d.getCreatedAt()); - data.put(Deployments.MODIFIED_AT, d.getModifiedAt()); - data.put(Deployments.SUBMISSION_TIME, d.getSubmissionTime()); - data.put(Deployments.START_TIME, d.getStartTime()); - data.put(Deployments.COMPLETION_TIME, d.getCompletionTime()); + 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; } - // CPD-ON + + 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/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 4f6ccb4..19ab9da 100644 --- 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 @@ -83,7 +83,8 @@ public void onCreate(CdsCreateEventContext context, List entries BckndResourceGroupsPostRequest.create().resourceGroupId(resourceGroupId); @SuppressWarnings("unchecked") - List> labels = (List>) entry.get(ResourceGroups.LABELS); + 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 @@ -180,8 +181,7 @@ private ResourceGroups toMap(BckndResourceGroup rg) { 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(); + var lm = com.sap.cds.feature.aicore.generated.cds4j.aicore.BckndResourceGroupLabel.create(); lm.setKey(l.getKey()); lm.setValue(l.getValue()); labels.add(lm); 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..8ede31b --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -0,0 +1,225 @@ +/* + * © 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.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.requires.AICore.maxRetries"), eq(Integer.class), any())) + .thenReturn(1); + when(env.getProperty(eq("cds.requires.AICore.initialDelayMs"), eq(Long.class), any())) + .thenReturn(1L); + when(env.getProperty(eq("cds.requires.AICore.resourceGroup"), eq(String.class), any())) + .thenReturn("default"); + when(env.getProperty(eq("cds.requires.AICore.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 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 index aab97da..898e1db 100644 --- 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 @@ -12,7 +12,7 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import java.lang.reflect.Field; -import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; import org.junit.jupiter.api.Test; class AICoreServiceImplTest { @@ -70,19 +70,21 @@ void notReadyYet_nullStatusCodeOnAllLevels_returnsFalse() { } @Test - void deploymentLocksFieldIsBoundedCaffeineCache() throws NoSuchFieldException { + 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(Cache.class); + assertThat(field.getType()).isEqualTo(ConcurrentHashMap.class); } @Test - void caffeineGetReturnsSameLockObjectForSameKey() { - Cache locks = - Caffeine.newBuilder().maximumSize(10_000).expireAfterAccess(Duration.ofHours(1)).build(); + void concurrentHashMapComputeIfAbsentReturnsSameLockObjectForSameKey() { + ConcurrentHashMap locks = new ConcurrentHashMap<>(); - Object lock1 = locks.get("rg-1", k -> new Object()); - Object lock2 = locks.get("rg-1", k -> new Object()); - Object differentRg = locks.get("rg-2", k -> new Object()); + 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); @@ -96,7 +98,7 @@ void clearTenantCacheRemovesAllRelatedEntries() throws Exception { AICoreServiceImpl service = freshService(); Cache tenantCache = readCache(service, "tenantResourceGroupCache"); Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - Cache deploymentLocks = readCache(service, "deploymentLocks"); + ConcurrentHashMap deploymentLocks = readLocks(service); tenantCache.put(tenantId, resourceGroupId); deploymentCache.put(resourceGroupId, "deployment-id"); @@ -106,7 +108,7 @@ void clearTenantCacheRemovesAllRelatedEntries() throws Exception { assertThat(tenantCache.asMap()).doesNotContainKey(tenantId); assertThat(deploymentCache.asMap()).doesNotContainKey(resourceGroupId); - assertThat(deploymentLocks.asMap()).doesNotContainKey(resourceGroupId); + assertThat(deploymentLocks).doesNotContainKey(resourceGroupId); } @Test @@ -119,7 +121,7 @@ void clearTenantCacheLeavesOtherTenantsUntouched() throws Exception { AICoreServiceImpl service = freshService(); Cache tenantCache = readCache(service, "tenantResourceGroupCache"); Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - Cache deploymentLocks = readCache(service, "deploymentLocks"); + ConcurrentHashMap deploymentLocks = readLocks(service); tenantCache.put(tenantA, resourceGroupA); tenantCache.put(tenantB, resourceGroupB); @@ -134,9 +136,7 @@ void clearTenantCacheLeavesOtherTenantsUntouched() throws Exception { assertThat(deploymentCache.asMap()) .doesNotContainKey(resourceGroupA) .containsKey(resourceGroupB); - assertThat(deploymentLocks.asMap()) - .doesNotContainKey(resourceGroupA) - .containsKey(resourceGroupB); + assertThat(deploymentLocks).doesNotContainKey(resourceGroupA).containsKey(resourceGroupB); } @Test @@ -145,7 +145,7 @@ void clearTenantCacheIsNoOpForUnknownTenant() throws Exception { AICoreServiceImpl service = freshService(); Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - Cache deploymentLocks = readCache(service, "deploymentLocks"); + ConcurrentHashMap deploymentLocks = readLocks(service); deploymentCache.put(resourceGroupId, "deployment-id"); deploymentLocks.put(resourceGroupId, new Object()); @@ -153,14 +153,14 @@ void clearTenantCacheIsNoOpForUnknownTenant() throws Exception { service.clearTenantCache("unknown-tenant"); assertThat(deploymentCache.asMap()).containsKey(resourceGroupId); - assertThat(deploymentLocks.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", Caffeine.newBuilder().build()); + setField(service, "deploymentLocks", new ConcurrentHashMap<>()); return service; } @@ -172,6 +172,14 @@ private static Cache readCache(AICoreServiceImpl service, String fi 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); diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index e933b1c..9b59766 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -121,7 +121,7 @@ annotate Books with { ```yaml cds: requires: - AICore: + recommendations: contextRowLimit: 2000 # Max historical rows used as training context ``` 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 index 68d09bd..4d7f75e 100644 --- a/cds-feature-recommendations/pom.xml +++ b/cds-feature-recommendations/pom.xml @@ -60,6 +60,21 @@ com.sap.cds cds-maven-plugin + + cds.install-node + + install-node + + + + cds.npm-ci + + npm + + + ci + + cds.compile 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 index df412c7..a76b8d8 100644 --- 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 @@ -65,6 +65,10 @@ Map buildRecommendations( 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)); diff --git a/cds-starter-ai/pom.xml b/cds-starter-ai/pom.xml index 962c8fb..8214031 100644 --- a/cds-starter-ai/pom.xml +++ b/cds-starter-ai/pom.xml @@ -9,10 +9,17 @@ cds-starter-ai + jar CDS Starter AI - Starter package for AI features in CAP Java applications + 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. diff --git a/pom.xml b/pom.xml index 1a39d1a..ac2ecb8 100644 --- a/pom.xml +++ b/pom.xml @@ -30,18 +30,18 @@ - - 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 - - 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 From 6bbb6fcc9c2c1cf2dd0a5d62030aa00825b4777c Mon Sep 17 00:00:00 2001 From: Marvin L Date: Wed, 10 Jun 2026 14:04:15 +0200 Subject: [PATCH 41/42] refactor: Migrate API Packages (#69) * refactor(ai-core): move public API to dedicated api package Move AICoreService and ModelDeploymentSpec from com.sap.cds.feature.aicore.core to com.sap.cds.feature.aicore.api so the public contract is clearly separated from the internal implementation. Add a package-info.java describing the api package. Internal classes (AbstractAICoreService, AICoreServiceImpl, MockAICoreServiceImpl, AICoreServiceConfiguration, handlers) and the unit test for AICoreServiceImpl get explicit imports for the relocated types. Implementation references in AICoreService javadoc use FQCN @link to avoid coupling the interface to internals. Addresses review feedback on PR #49 (move APIs to api package). * refactor(recommendations): move public API to dedicated api package Move RecommendationClient, RecommendationClientResolver, RptInferenceClient and RptModelSpec from com.sap.cds.feature.recommendation to com.sap.cds.feature.recommendation.api so the public contract is clearly separated from the internal implementation. Add a package-info.java for the api package. RecommendationClient and RecommendationClientResolver are promoted from package-private to public to be usable from outside the plugin. Internal classes (FioriRecommendationHandler, MockRecommendationClient, RecommendationConfiguration) and tests get explicit imports for the relocated types. Addresses review feedback on PR #49 (move APIs to api package). * test(integration): update imports for relocated api packages Update test imports in integration-tests/spring and integration-tests/mtx-local to reference AICoreService, ModelDeploymentSpec and RptModelSpec from their new api packages. Addresses review feedback on PR #49 (move APIs to api package). * samples(bookshop): update imports for relocated api packages Update AICoreShowcaseHandler imports to reference AICoreService, RptInferenceClient and RptModelSpec from their new api packages. Addresses review feedback on PR #49 (move APIs to api package). --- .../feature/aicore/{core => api}/AICoreService.java | 7 ++++--- .../aicore/{core => api}/ModelDeploymentSpec.java | 2 +- .../com/sap/cds/feature/aicore/api/package-info.java | 10 ++++++++++ .../aicore/core/AICoreServiceConfiguration.java | 1 + .../sap/cds/feature/aicore/core/AICoreServiceImpl.java | 2 ++ .../cds/feature/aicore/core/AbstractAICoreService.java | 1 + .../cds/feature/aicore/core/MockAICoreServiceImpl.java | 1 + .../cds/feature/aicore/core/handler/ActionHandler.java | 2 +- .../aicore/core/handler/ConfigurationHandler.java | 2 +- .../feature/aicore/core/handler/DeploymentHandler.java | 2 +- .../feature/aicore/core/handler/MockEntityHandler.java | 2 +- .../aicore/core/handler/ResourceGroupHandler.java | 2 +- .../aicore/core/AICoreServiceImplDeploymentIdTest.java | 2 ++ .../recommendation/FioriRecommendationHandler.java | 4 +++- .../recommendation/MockRecommendationClient.java | 1 + .../recommendation/RecommendationConfiguration.java | 6 +++++- .../recommendation/{ => api}/RecommendationClient.java | 4 ++-- .../{ => api}/RecommendationClientResolver.java | 6 +++--- .../recommendation/{ => api}/RptInferenceClient.java | 2 +- .../feature/recommendation/{ => api}/RptModelSpec.java | 4 ++-- .../cds/feature/recommendation/api/package-info.java | 10 ++++++++++ .../recommendation/FioriRecommendationHandlerTest.java | 3 ++- .../RecommendationConfigurationTest.java | 2 +- .../cds/feature/aicore/itest/mt/MtxLifecycleTest.java | 2 +- .../aicore/itest/mt/SubscribeUnsubscribeTest.java | 2 +- .../feature/aicore/itest/mt/TenantIsolationTest.java | 2 +- .../cds/feature/aicore/itest/AICoreServiceTest.java | 4 ++-- .../com/sap/cds/feature/aicore/itest/ActionTest.java | 4 ++-- .../cds/feature/aicore/itest/BaseIntegrationTest.java | 4 ++-- .../sap/cds/feature/aicore/itest/MultiTenancyTest.java | 2 +- .../bookshop/handlers/AICoreShowcaseHandler.java | 6 +++--- 31 files changed, 70 insertions(+), 34 deletions(-) rename cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/{core => api}/AICoreService.java (93%) rename cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/{core => api}/ModelDeploymentSpec.java (97%) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/package-info.java rename cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/{ => api}/RecommendationClient.java (72%) rename cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/{ => api}/RecommendationClientResolver.java (54%) rename cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/{ => api}/RptInferenceClient.java (98%) rename cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/{ => api}/RptModelSpec.java (93%) create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/package-info.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java similarity index 93% rename from cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java rename to cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java index 6222911..6c90866 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ -package com.sap.cds.feature.aicore.core; +package com.sap.cds.feature.aicore.api; import com.sap.cds.services.cds.CqnService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; @@ -25,8 +25,9 @@ * consistent transient-error handling. * * - *

Two implementations are provided: {@link AICoreServiceImpl} (when an SAP AI Core service - * binding is detected) and {@link MockAICoreServiceImpl} (in-memory fallback for local + *

Two implementations are provided: {@link com.sap.cds.feature.aicore.core.AICoreServiceImpl} + * (when an SAP AI Core service binding is detected) and {@link + * com.sap.cds.feature.aicore.core.MockAICoreServiceImpl} (in-memory fallback for local * development). */ public interface AICoreService extends CqnService { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java similarity index 97% rename from cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java rename to cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java index 4df6194..a51d1b6 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/ModelDeploymentSpec.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ -package com.sap.cds.feature.aicore.core; +package com.sap.cds.feature.aicore.api; import com.sap.ai.sdk.core.model.AiDeployment; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; 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 index 4fd552f..1e31c9b 100644 --- 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 @@ -7,6 +7,7 @@ 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; 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 index db39072..91174cd 100644 --- 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 @@ -20,6 +20,8 @@ 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; 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 index 435e74f..8b8d080 100644 --- 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 @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.aicore.core; +import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.utils.services.AbstractCqnService; import java.util.Map; 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 index 1b385bb..c54b4b9 100644 --- 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 @@ -3,6 +3,7 @@ */ 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; 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 index d255a90..b454a2f 100644 --- 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 @@ -6,7 +6,7 @@ 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.core.AICoreService; +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; 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 index 98d9728..7ad70de 100644 --- 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 @@ -8,7 +8,7 @@ import com.sap.ai.sdk.core.model.AiConfigurationBaseData; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index 750d140..d5907b7 100644 --- 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 @@ -10,7 +10,7 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index 72a86fc..cef5666 100644 --- 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 @@ -4,7 +4,7 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index 19ab9da..3295ebe 100644 --- 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 @@ -10,7 +10,7 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index 8ede31b..1d7b670 100644 --- 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 @@ -22,6 +22,8 @@ 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; 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 index 4771c9d..da157fb 100644 --- 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 @@ -6,7 +6,9 @@ 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.core.AICoreService; +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; 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 index 6560726..27498bb 100644 --- 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 @@ -4,6 +4,7 @@ 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; 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 index 427d8a4..bfbcc34 100644 --- 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 @@ -3,8 +3,12 @@ */ package com.sap.cds.feature.recommendation; -import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.api.AICoreService; 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; diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java similarity index 72% rename from cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java rename to cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java index 82213d3..8694745 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -1,12 +1,12 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ -package com.sap.cds.feature.recommendation; +package com.sap.cds.feature.recommendation.api; import com.sap.cds.CdsData; import java.util.List; -interface RecommendationClient { +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/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java similarity index 54% rename from cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java rename to cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index 76b9407..38db2ad 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -1,12 +1,12 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ -package com.sap.cds.feature.recommendation; +package com.sap.cds.feature.recommendation.api; -import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.api.AICoreService; @FunctionalInterface -interface RecommendationClientResolver { +public interface RecommendationClientResolver { RecommendationClient resolve(AICoreService aiCoreService, String tenantId); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java similarity index 98% rename from cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java rename to cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 83247fc..0616340 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ -package com.sap.cds.feature.recommendation; +package com.sap.cds.feature.recommendation.api; import com.fasterxml.jackson.core.type.TypeReference; import com.sap.ai.sdk.core.JacksonConfiguration; diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptModelSpec.java similarity index 93% rename from cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java rename to cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptModelSpec.java index 5b70b25..2dd1b20 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptModelSpec.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptModelSpec.java @@ -1,11 +1,11 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ -package com.sap.cds.feature.recommendation; +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.core.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; import java.util.List; import java.util.Map; 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/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index 6ab17fa..23ede2c 100644 --- 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 @@ -13,7 +13,8 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.ResultBuilder; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index 9926497..a4173e3 100644 --- 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 @@ -5,7 +5,7 @@ import static org.mockito.Mockito.*; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index d303bf5..01a8d83 100644 --- 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 @@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index 7cb2680..a38467a 100644 --- 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 @@ -9,7 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index 989ecfc..9b1c771 100644 --- 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 @@ -6,7 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index e7f335a..955364e 100644 --- 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 @@ -5,9 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import com.sap.cds.feature.recommendation.RptModelSpec; +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; 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 index 21c5b2d..cc7e592 100644 --- 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 @@ -9,9 +9,9 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AICoreService; +import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import com.sap.cds.feature.recommendation.RptModelSpec; +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; 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 index 6eb8168..b474950 100644 --- 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 @@ -6,8 +6,8 @@ import com.sap.cds.Result; import com.sap.cds.Row; import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import com.sap.cds.feature.aicore.core.AICoreService; -import com.sap.cds.feature.recommendation.RptModelSpec; +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; 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 index 15f1ff0..c845f13 100644 --- 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 @@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; -import com.sap.cds.feature.aicore.core.AICoreService; +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; 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 index d9196f9..8923382 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -2,9 +2,9 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.core.AICoreService; -import com.sap.cds.feature.recommendation.RptInferenceClient; -import com.sap.cds.feature.recommendation.RptModelSpec; +import com.sap.cds.feature.aicore.api.AICoreService; +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; From 24aa02c1c7e66dd805a7c15c4976f9708c5e7702 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Fri, 12 Jun 2026 12:35:49 +0200 Subject: [PATCH 42/42] Comments from #49: Tenant Scoping (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make cache for entitiesWithoutPredictionsPerTenant tenant specific * refactor(ai-core): make AICoreService tenant-agnostic and DI-friendly - Replace resourceGroupForTenant(String) with resourceGroup() on the public AICoreService interface. The implementation reads the tenant from the current RequestContext internally. - Remove isMultiTenancyEnabled() and getRetry() from the public interface; they remain accessible on AbstractAICoreService for internal callers. - Remove the CDS function 'resourceGroupForTenant' from index.cds and its action handler. - Detect multi-tenancy via standard CAP Java cds.multiTenancy.sidecar.url property and DeploymentService presence instead of custom flag. - Update RecommendationClientResolver to drop tenantId parameter. - Update samples, tests, and javadoc accordingly. Addresses review comments from PR #49 (Issue 2). * feat(ai-core): restrict AICore entity APIs to current tenant - Add tenant ownership verification on ResourceGroupHandler for READ (by-key), UPDATE, and DELETE operations. Returns 404 if the resource group belongs to a different tenant. - Scope list queries (READ without key) to the current tenant's resource groups via the tenant label filter in multi-tenancy mode. - Add ensureResourceGroupAccessible() guard to DeploymentHandler and ConfigurationHandler, validating the addressed resource group belongs to the current tenant before forwarding to AI Core. - Provider/system users are exempt from tenant restrictions and can access all resource groups (useful for ops/debug scenarios). - Add isProviderUser() and currentTenantId() as public helpers on AbstractAICoreService for use by handler classes. Addresses review comments from PR #49 (Issue 3a). * chore(ai-core): rename config namespace to cds.ai.core - Rename all configuration properties from cds.requires.AICore.* to cds.ai.core.* to align with CAP Java property naming conventions. - Rename cds.requires.recommendations.contextRowLimit to cds.ai.recommendations.contextRowLimit. - Drop the cds.requires.AICore.multiTenancy flag entirely; multi- tenancy is now auto-detected from standard CAP Java properties. - Update README with new configuration namespace and examples. Addresses review comments from PR #49 (Issue 3b). * fix(ai-core): handle null tenant in resourceGroupForTenant When resourceGroupForTenant is called with a null tenantId (which happens when currentTenantId() returns null in single-tenant or non-tenant-scoped RequestContexts), fall back to the default resource group instead of passing null to the Caffeine cache (which throws NPE). This fixes integration test failures in the CI pipeline where the ApplicationServiceDelegation and Recommendation tests run without an explicit tenant in the RequestContext. * fix(ci): cleanup all run attempts and cds-itest resource groups The cleanup step previously only deleted resource groups matching the exact current run_id AND run_attempt. When a run failed and was re-run, the previous attempt's resource groups were never cleaned up, eventually hitting the AI Core resource group limit (50). Changes: - Match prefix 'itest-{run_id}-' (all attempts) instead of the exact 'itest-{run_id}-{run_attempt}' string. - Same for 'sonar-{run_id}-' prefix. - Also delete 'cds-itest-' prefixed resource groups which are created by the multi-tenancy integration tests via resourceGroupForTenant() and were never cleaned up by the pipeline. * fix(itest): align config namespace with cds.ai.core rename The source code (commit c30080b) renamed properties from cds.requires.AICore.* to cds.ai.core.*, but the integration test application.yaml files were not updated. This meant the CDS_AICORE_TEST_RESOURCE_GROUP env var set by CI was silently ignored and tests always ran against the literal default resource group. - spring/application.yaml: cds.requires.AICore -> cds.ai.core - mtx-local/application.yaml: remove obsolete cds.requires.AICore.multiTenancy (now auto-detected from cds.multi-tenancy.sidecar.url) * test(ai-core): add unit tests for tenant scoping and mock service Cover new code paths introduced by the tenant-scoping branch: - TenantScopingTest (7 tests): exercises every branch of AbstractCrudHandler.ensureResourceGroupAccessible() — provider bypass, single-tenancy bypass, null tenant, matching/non-matching labels, 404. - MockAICoreServiceImplTest (9 tests): both constructors, MT enabled/disabled, resourceGroupForTenant, cache isolation, clearTenantCache, getRetry, config property reads. - AICoreServiceImplDeploymentIdTest (+2 tests): resourceGroupForTenant(null) returns default even with MT enabled; single-tenancy always returns default. * chore(recommendations): add TODO for model-changed integration test Document the missing E2E coverage for RecommendationModelChangedHandler. The proper test requires an extensibility-enabled sidecar with extension JSON that adds prediction columns — not yet set up in mtx-local. The cache-invalidation logic itself is covered by the existing unit test FioriRecommendationHandlerTest.invalidateTenant_removesOnlyThatTenantsEntries. * update cleanup * fix(ci): scope resource group cleanup to own job only Each parallel CI job (Java 17, Java 21, SonarQube) was using broad prefixes in its cleanup step, deleting resource groups belonging to sibling jobs still in progress. This caused intermittent 403 Forbidden errors when the affected jobs tried to use their now-deleted resource groups. Narrow the cleanup prefixes so each job only deletes its own: - integration-tests: itest-{run_id}-{attempt}-j{version}* - scan-with-sonar: sonar-{run_id}-{attempt}* Both still clean up itest-rg-* (ResourceGroupTest leftovers). * test(ai-core): add unit tests for uncovered code paths - DeploymentHandler: test onCreate (with/without TTL) and onUpdate happy path (targetStatus and configurationId branches) - ResourceGroupHandler: test onUpdate with/without labels, buildTenantLabelSelector branches (tenantId filter, MT non-provider, MT null tenant, single tenancy), ensureOwnedByCurrentTenant branches (provider, single tenant, wrong tenant, matching tenant) - AICoreServiceConfiguration: test eventHandlers() MockAICoreServiceImpl branch (with and without multi-tenancy), test detectMultiTenancy via services() for sidecarUrl branch and no-MT fallback * fix(ci): include cds-itest- prefix in resource group cleanup The MultiTenancyTest creates per-tenant resource groups with names like cds-itest-mt-a-{timestamp} (from resourceGroupPrefix 'cds-' + tenant name 'itest-*'). These are unique per test run (timestamped) and safe to clean up from any job without cross-job interference. * refactor: extract inline cleanup script into shared JS file --------- Co-authored-by: Marvin L --- .github/actions/integration-tests/action.yml | 8 + .github/actions/scan-with-sonar/action.yml | 8 + .github/scripts/cleanup-resource-groups.js | 115 ++++++ .github/workflows/pipeline.yml | 61 ---- cds-feature-ai-core/README.md | 20 +- .../cds/feature/aicore/api/AICoreService.java | 35 +- .../core/AICoreServiceConfiguration.java | 23 +- .../aicore/core/AICoreServiceImpl.java | 16 +- .../aicore/core/AbstractAICoreService.java | 47 +++ .../aicore/core/MockAICoreServiceImpl.java | 11 +- .../core/handler/AbstractCrudHandler.java | 28 ++ .../aicore/core/handler/ActionHandler.java | 13 - .../core/handler/ConfigurationHandler.java | 2 + .../core/handler/DeploymentHandler.java | 4 + .../core/handler/ResourceGroupHandler.java | 61 +++- .../resources/cds/com.sap.cds/ai/index.cds | 2 - .../core/AICoreServiceConfigurationTest.java | 177 +++++++++ .../AICoreServiceImplDeploymentIdTest.java | 42 ++- .../core/MockAICoreServiceImplTest.java | 106 ++++++ .../core/handler/DeploymentHandlerTest.java | 133 ++++++- .../handler/ResourceGroupHandlerTest.java | 345 +++++++++++++++++- .../core/handler/TenantScopingTest.java | 154 ++++++++ .../FioriRecommendationHandler.java | 25 +- .../RecommendationConfiguration.java | 14 +- .../RecommendationModelChangedHandler.java | 36 ++ .../api/RecommendationClientResolver.java | 2 +- .../api/RptInferenceClient.java | 5 +- .../FioriRecommendationHandlerTest.java | 50 ++- .../srv/src/main/resources/application.yaml | 3 - .../src/main/resources/application.yaml | 4 +- .../handlers/AICoreShowcaseHandler.java | 15 +- 31 files changed, 1391 insertions(+), 174 deletions(-) create mode 100644 .github/scripts/cleanup-resource-groups.js create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 49a4060..a37d83c 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -34,3 +34,11 @@ runs: 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 index c01bf24..9201134 100644 --- a/.github/actions/scan-with-sonar/action.yml +++ b/.github/actions/scan-with-sonar/action.yml @@ -48,6 +48,14 @@ runs: 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 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/pipeline.yml b/.github/workflows/pipeline.yml index 2e7b238..251b68a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -65,67 +65,6 @@ jobs: java-version: ${{ matrix.java-version }} maven-version: ${{ env.MAVEN_VERSION }} - integration-tests-cleanup: - name: Cleanup Integration Test Resources - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [integration-tests, sonarqube-scan] - if: always() - permissions: - contents: read - 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: Cleanup resource groups - working-directory: integration-tests - shell: bash - run: | - cds bind --exec -- node -e " - const https = require('https'); - const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}'); - const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null'); - if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); } - const tokenUrl = key.url + '/oauth/token'; - const apiUrl = key.serviceurls.AI_API_URL; - function fetch(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',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data})); - }); - req.on('error',reject); - if(opts.body) req.write(opts.body); - req.end(); - }); - } - (async () => { - const tokenParams = new URLSearchParams({grant_type:'client_credentials'}); - const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64'); - const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}}); - const token = JSON.parse(tokenRes.body).access_token; - const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'}; - const prefixes = ['itest-${{ github.run_id }}-${{ github.run_attempt }}', 'sonar-${{ github.run_id }}-${{ github.run_attempt }}']; - const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers}); - const groups = JSON.parse(rgRes.body).resources || []; - const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p))); - for (const rg of toDelete) { - const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers}); - console.log('Delete', rg.resourceGroupId, '->', res.status); - } - console.log('Cleaned up', toDelete.length, 'resource groups for run ${{ github.run_id }}'); - })().catch(e => { console.error(e.message); process.exit(1); }); - " - local-mtx-tests: name: Local MTX Tests (Java ${{ matrix.java-version }}) runs-on: ubuntu-latest diff --git a/cds-feature-ai-core/README.md b/cds-feature-ai-core/README.md index c447859..1b1125f 100644 --- a/cds-feature-ai-core/README.md +++ b/cds-feature-ai-core/README.md @@ -36,19 +36,21 @@ Without a binding the plugin registers a mock implementation. ## Configuration -All configuration is under the `cds.requires.AICore` namespace in `application.yaml`: +All configuration is under the `cds.ai.core` namespace in `application.yaml`: ```yaml cds: - requires: - AICore: + ai: + core: resourceGroup: default # Resource group for single-tenant mode resourceGroupPrefix: "cds-" # Prefix for auto-created tenant resource groups - multiTenancy: false # Enable per-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: @@ -61,11 +63,11 @@ The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs | `AICore.deployments` | READ, CREATE, DELETE | Deployment management with status tracking | | `AICore.configurations` | READ, CREATE | Configuration management for scenarios and executables | -### Functions & Actions +### Programmatic API ```java -// Get the resource group ID for a CDS tenant -String rgId = aiCoreService.resourceGroupForTenant(tenantId); +// 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()); @@ -73,7 +75,7 @@ String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1()); ## Multi-Tenancy -When `cds.requires.AICore.multiTenancy=true`: +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 @@ -92,7 +94,7 @@ AICoreService aiCore = runtime.getServiceCatalog() Result rgs = aiCore.run(Select.from("AICore.resourceGroups")); // Resolve a deployment and obtain a configured ApiClient for it -String resourceGroupId = aiCore.resourceGroupForTenant(tenantId); +String resourceGroupId = aiCore.resourceGroup(); String deploymentId = aiCore.deploymentId(resourceGroupId, RptModelSpec.rpt1()); ApiClient client = aiCore.inferenceClient(resourceGroupId, deploymentId); ``` 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 index 6c90866..40374bb 100644 --- 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 @@ -5,7 +5,6 @@ import com.sap.cds.services.cds.CqnService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import io.github.resilience4j.retry.Retry; /** * CAP service contract for SAP AI Core integration. @@ -15,20 +14,16 @@ * provides programmatic helpers to: * *

    - *
  • Resolve the resource group ID for a CDS tenant ({@link #resourceGroupForTenant(String)}), - * creating it on-demand when multi-tenancy is enabled. + *
  • Resolve the resource group ID for the current tenant ({@link #resourceGroup()}), creating + * it on-demand when multi-tenancy is enabled. *
  • Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link * #deploymentId(String, ModelDeploymentSpec)}). *
  • Build an {@link ApiClient} preconfigured for inference against a specific deployment * ({@link #inferenceClient(String, String)}). - *
  • Expose a shared retry/backoff policy ({@link #getRetry()}) for downstream callers that want - * consistent transient-error handling. *
* - *

Two implementations are provided: {@link com.sap.cds.feature.aicore.core.AICoreServiceImpl} - * (when an SAP AI Core service binding is detected) and {@link - * com.sap.cds.feature.aicore.core.MockAICoreServiceImpl} (in-memory fallback for local - * development). + *

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 { @@ -45,16 +40,15 @@ public interface AICoreService extends CqnService { String CONFIGURATIONS = "AICore.configurations"; /** - * Returns the AI Core resource group ID associated with the given CDS tenant. + * Returns the AI Core resource group ID associated with the current tenant. * - *

When multi-tenancy is disabled the configured {@code cds.requires.AICore.resourceGroup} is - * returned for every tenant. 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. + *

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. * - * @param tenantId the CDS tenant identifier; may be {@code null} when multi-tenancy is disabled - * @return the AI Core resource group ID + * @return the AI Core resource group ID for the current tenant */ - String resourceGroupForTenant(String tenantId); + String resourceGroup(); /** * Returns the deployment ID for the given model spec inside the given resource group. @@ -80,13 +74,4 @@ public interface AICoreService extends CqnService { * @return a configured {@link ApiClient} pointing at the deployment's inference endpoint */ ApiClient inferenceClient(String resourceGroupId, String deploymentId); - - /** Returns whether multi-tenancy is enabled for this service. */ - boolean isMultiTenancyEnabled(); - - /** - * Returns the shared {@link Retry} used internally for transient AI Core errors. Exposed so - * downstream inference clients can reuse the same backoff policy. - */ - Retry getRetry(); } 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 index 1e31c9b..bfd4c8c 100644 --- 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 @@ -14,6 +14,8 @@ 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; @@ -50,16 +52,27 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) { 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 = - runtime - .getEnvironment() - .getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false); + boolean multiTenancyEnabled = detectMultiTenancy(runtime); if (hasBinding) { AICoreServiceImpl service = @@ -75,7 +88,7 @@ public void services(CdsRuntimeConfigurer configurer) { logger.info("Registered AICoreService backed by AI Core binding."); } else { MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); configurer.service(mockService); logger.info("Registered MockAICoreService (no AI Core binding found)."); } 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 index 91174cd..52eeb4f 100644 --- 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 @@ -25,7 +25,6 @@ import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.environment.CdsEnvironment; -import com.sap.cds.services.request.RequestContext; 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; @@ -100,14 +99,14 @@ public AICoreServiceImpl( this.multiTenancyEnabled = multiTenancyEnabled; CdsEnvironment env = runtime.getEnvironment(); this.maxRetries = - env.getProperty("cds.requires.AICore.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); + env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); this.initialDelayMs = - env.getProperty("cds.requires.AICore.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); + env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); this.defaultResourceGroup = - env.getProperty("cds.requires.AICore.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); + env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); this.resourceGroupPrefix = env.getProperty( - "cds.requires.AICore.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); + "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); this.retry = buildRetry(maxRetries, initialDelayMs); this.tenantResourceGroupCache = newCache(); this.resourceGroupDeploymentCache = newCache(); @@ -126,8 +125,8 @@ private static Cache newCache() { @Override public String resourceGroupForTenant(String tenantId) { - if (!multiTenancyEnabled) { - logger.debug("Multi-tenancy disabled, using resource group {}", defaultResourceGroup); + if (!multiTenancyEnabled || tenantId == null) { + logger.debug("Using default resource group {}", defaultResourceGroup); return defaultResourceGroup; } return getOrCreateResourceGroupForTenant(tenantId); @@ -243,8 +242,7 @@ public String resolveResourceGroupFromKeys(Map keys) { if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { return (String) rgMap.get("resourceGroupId"); } - String tenantId = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - return resourceGroupForTenant(tenantId); + return resourceGroup(); } @Override 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 index 8b8d080..0e7ec79 100644 --- 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 @@ -4,8 +4,11 @@ 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; /** @@ -24,6 +27,50 @@ 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(); 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 index c54b4b9..aaee071 100644 --- 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 @@ -26,16 +26,19 @@ public class MockAICoreServiceImpl extends AbstractAICoreService { 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.requires.AICore.resourceGroup", String.class, "default"); + env.getProperty("cds.ai.core.resourceGroup", String.class, "default"); this.resourceGroupPrefix = - env.getProperty("cds.requires.AICore.resourceGroupPrefix", String.class, "cds-"); - this.multiTenancyEnabled = - env.getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false); + env.getProperty("cds.ai.core.resourceGroupPrefix", String.class, "cds-"); + this.multiTenancyEnabled = multiTenancyEnabled; } @Override 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 index 402ab9b..0549daa 100644 --- 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 @@ -3,7 +3,10 @@ */ 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; @@ -22,6 +25,31 @@ 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( 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 index b454a2f..810068b 100644 --- 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 @@ -12,7 +12,6 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.request.RequestContext; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,18 +25,6 @@ public ActionHandler(AICoreServiceImpl service) { super(service); } - @On(event = "resourceGroupForTenant") - public void onResourceGroupForTenant(EventContext context) { - Map params = asMap(context.get("data")); - String tenant = (String) params.get("tenant"); - if (tenant == null) { - tenant = RequestContext.getCurrent(service.getRuntime()).getUserInfo().getTenant(); - } - String result = service.resourceGroupForTenant(tenant); - context.put("result", result); - context.setCompleted(); - } - @On(event = "stop", entity = AICoreService.DEPLOYMENTS) public void onStop(EventContext context) { Map keys = asMap(context.get("keys")); 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 index 7ad70de..bafdbff 100644 --- 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 @@ -52,6 +52,7 @@ public void onRead(CdsReadEventContext context) { Map values = analysis.targetValues(); String resourceGroupId = resolveResourceGroup(merge(keys, values)); + ensureResourceGroupAccessible(resourceGroupId); logger.debug( "Reading configurations for resourceGroup={}, keys={}, values={}", resourceGroupId, @@ -79,6 +80,7 @@ public void onCreate(CdsCreateEventContext context, List entries for (Configurations entry : entries) { String resourceGroupId = resolveResourceGroup(entry); + ensureResourceGroupAccessible(resourceGroupId); AiConfigurationBaseData request = AiConfigurationBaseData.create() 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 index d5907b7..0b3df10 100644 --- 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 @@ -56,6 +56,7 @@ public void onRead(CdsReadEventContext context) { Map values = analysis.targetValues(); String resourceGroupId = resolveResourceGroup(merge(keys, values)); + ensureResourceGroupAccessible(resourceGroupId); String id = (String) keys.get(Deployments.ID); if (id != null) { @@ -75,6 +76,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { for (Deployments entry : entries) { String resourceGroupId = resolveResourceGroup(entry); + ensureResourceGroupAccessible(resourceGroupId); String configurationId = entry.getConfigurationId(); AiDeploymentCreationRequest request = @@ -111,6 +113,7 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(merge(keys, data)); + ensureResourceGroupAccessible(resourceGroupId); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); @@ -135,6 +138,7 @@ public void onDelete(CdsDeleteEventContext context) { 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); 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 index 3295ebe..b0e7094 100644 --- 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 @@ -19,6 +19,8 @@ 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; @@ -60,13 +62,10 @@ public void onRead(CdsReadEventContext context) { if (resourceGroupId != null) { BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); + ensureOwnedByCurrentTenant(rg); context.setResult(List.of(toMap(rg))); } else { - List labelSelector = null; - if (values.containsKey(ResourceGroups.TENANT_ID)) { - String tenantId = (String) values.get(ResourceGroups.TENANT_ID); - labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); - } + List labelSelector = buildTenantLabelSelector(values); BckndResourceGroupList result = resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); context.setResult(mapResources(result.getResources(), this::toMap)); @@ -124,6 +123,7 @@ public void onUpdate(CdsUpdateEventContext context) { Map keys = analyzer.analyze(update).targetKeys(); String resourceGroupId = resolveResourceGroupId(keys); + ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); Map data = update.entries().get(0); BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @@ -147,6 +147,8 @@ public void onDelete(CdsDeleteEventContext context) { 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()); @@ -159,7 +161,54 @@ private String resolveResourceGroupId(Map keys) { if (keys.containsKey(ResourceGroups.TENANT_ID)) { return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); } - return service.getDefaultResourceGroup(); + 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) { 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 index f60e272..d823f49 100644 --- 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 @@ -90,8 +90,6 @@ service AICore { on 1 = 1; }; - function resourceGroupForTenant(tenant: String) returns String; - type BckndResourceGroupLabels : many BckndResourceGroupLabel; type BckndResourceGroupLabel { 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 index 1d7b670..f5e3396 100644 --- 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 @@ -68,13 +68,13 @@ void setUp() { 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.requires.AICore.maxRetries"), eq(Integer.class), any())) + when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) .thenReturn(1); - when(env.getProperty(eq("cds.requires.AICore.initialDelayMs"), eq(Long.class), any())) + when(env.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())) .thenReturn(1L); - when(env.getProperty(eq("cds.requires.AICore.resourceGroup"), eq(String.class), any())) + when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("default"); - when(env.getProperty(eq("cds.requires.AICore.resourceGroupPrefix"), eq(String.class), any())) + when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) .thenReturn("cds-"); service = @@ -192,6 +192,40 @@ void secondCallUsesCachedResult_singleQueryToApi() { 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. 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/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index fb4211b..cc26a3c 100644 --- 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 @@ -5,21 +5,38 @@ 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) @@ -27,7 +44,8 @@ class DeploymentHandlerTest { @Mock private AICoreServiceImpl service; @Mock private DeploymentApi deploymentApi; - @Mock private CdsUpdateEventContext context; + @Mock private CdsUpdateEventContext updateContext; + @Mock private CdsCreateEventContext createContext; private DeploymentHandler cut; @@ -41,7 +59,7 @@ void setup() { void onUpdate_emptyEntries_throwsBadRequest() { List entries = List.of(); - assertThatThrownBy(() -> cut.onUpdate(context, entries)) + assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) .isInstanceOfSatisfying( ServiceException.class, e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) @@ -54,7 +72,7 @@ void onUpdate_emptyEntries_throwsBadRequest() { void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { List entries = List.of(Deployments.of(Map.of("ttl", "1d"))); - assertThatThrownBy(() -> cut.onUpdate(context, entries)) + assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) .isInstanceOfSatisfying( ServiceException.class, e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) @@ -63,4 +81,113 @@ void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { 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 index 2debb0d..14a828b 100644 --- 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 @@ -4,23 +4,44 @@ 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) @@ -28,7 +49,7 @@ class ResourceGroupHandlerTest { @Mock private AICoreServiceImpl service; @Mock private ResourceGroupApi resourceGroupApi; - @Mock private CdsCreateEventContext context; + @Mock private CdsCreateEventContext createContext; private ResourceGroupHandler handler; @@ -43,7 +64,7 @@ void onCreate_withTenantIdOnly_setsOnlyTenantLabel() { Map entry = Map.of("resourceGroupId", "rg-1", "tenantId", "tenant-a"); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getResourceGroupId()).isEqualTo("rg-1"); @@ -62,7 +83,7 @@ void onCreate_withLabelsOnly_setsOnlyUserLabels() { List.of(Map.of("key", "env", "value", "prod"), Map.of("key", "team", "value", "ai"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getResourceGroupId()).isEqualTo("rg-2"); @@ -83,7 +104,7 @@ void onCreate_withTenantIdAndLabels_keepsTenantLabelAndUserLabels() { List.of(Map.of("key", "env", "value", "prod"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); // Tenant label first, then user-supplied labels — and tenant label is NOT lost. @@ -105,7 +126,7 @@ void onCreate_userSuppliedTenantLabelTakesPrecedence() { List.of(Map.of("key", AICoreServiceImpl.TENANT_LABEL_KEY, "value", "tenant-user"))); List entries = List.of(ResourceGroups.of(entry)); - handler.onCreate(context, entries); + handler.onCreate(createContext, entries); BckndResourceGroupsPostRequest request = captureCreateRequest(); assertThat(request.getLabels()) @@ -113,6 +134,320 @@ void onCreate_userSuppliedTenantLabelTakesPrecedence() { .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); 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/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index da157fb..e7ea5d5 100644 --- 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 @@ -33,7 +33,10 @@ class FioriRecommendationHandler implements EventHandler { private final AICoreService aiCoreService; private final RecommendationClientResolver clientResolver; private final RecommendationResultParser resultParser = new RecommendationResultParser(); - private final Cache entitiesWithoutPredictions = + // 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( @@ -42,14 +45,25 @@ class FioriRecommendationHandler implements EventHandler { 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(); - if (entitiesWithoutPredictions.getIfPresent(entityName) != null) { + String cacheKey = tenantKey(tenantId) + ":" + entityName; + if (entitiesWithoutPredictionsPerTenant.getIfPresent(cacheKey) != null) { return; } @@ -77,14 +91,14 @@ public void afterRead(CdsReadEventContext context, List dataList) { .getCdsRuntime() .getEnvironment() .getProperty( - "cds.requires.recommendations.contextRowLimit", + "cds.ai.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); var builder = new RecommendationContextBuilder(target, rowType, limit); if (builder.predictionElementNames().isEmpty()) { - entitiesWithoutPredictions.put(entityName, Boolean.TRUE); + entitiesWithoutPredictionsPerTenant.put(cacheKey, Boolean.TRUE); return; } @@ -111,8 +125,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { List allRows = builder.assembleRows(contextRows, predictRow, row); - String tenantId = context.getUserInfo().getTenant(); - RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); + RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); 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 index bfbcc34..879f0fe 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -35,16 +36,19 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { RecommendationClientResolver resolver = aiCoreService instanceof MockAICoreServiceImpl - ? (service, tenantId) -> new MockRecommendationClient() + ? service -> new MockRecommendationClient() : RecommendationConfiguration::resolveRptClient; - configurer.eventHandler(new FioriRecommendationHandler(aiCoreService, resolver)); + FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + configurer.eventHandler(handler); + configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } - private static RecommendationClient resolveRptClient(AICoreService service, String tenantId) { - String resourceGroup = service.resourceGroupForTenant(tenantId); + private static RecommendationClient resolveRptClient(AICoreService service) { + String resourceGroup = service.resourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); return new RptInferenceClient( - service.inferenceClient(resourceGroup, deploymentId), service.getRetry()); + service.inferenceClient(resourceGroup, deploymentId), + ((AbstractAICoreService) service).getRetry()); } } 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/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index 38db2ad..ecc6837 100644 --- 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 @@ -8,5 +8,5 @@ @FunctionalInterface public interface RecommendationClientResolver { - RecommendationClient resolve(AICoreService aiCoreService, String tenantId); + 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 index 0616340..2865dd3 100644 --- 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 @@ -31,10 +31,11 @@ * *

{@code
  * AICoreService service = ...;
- * String rg = service.resourceGroupForTenant(tenantId);
+ * String rg = service.resourceGroup();
  * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
  * RptInferenceClient client =
- *     new RptInferenceClient(service.inferenceClient(rg, deploymentId), service.getRetry());
+ *     new RptInferenceClient(service.inferenceClient(rg, deploymentId),
+ *         ((AbstractAICoreService) service).getRetry());
  * List predictions = client.predict(rows, List.of("targetColumn"), "ID");
  * }
*/ 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 index 23ede2c..c7f1a22 100644 --- 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 @@ -67,7 +67,7 @@ void setup() { reset(db); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); predictionClient = randomPickClient(); - cut = new FioriRecommendationHandler(aiCoreService, (service, tenantId) -> predictionClient); + cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient); } // ── tests ────────────────────────────────────────────────────────────────── @@ -297,6 +297,50 @@ void rptStyleClient_filledColumns_areExcludedFromRecommendations() { }); } + @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) { @@ -318,6 +362,10 @@ 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"); diff --git a/integration-tests/mtx-local/srv/src/main/resources/application.yaml b/integration-tests/mtx-local/srv/src/main/resources/application.yaml index df8c3e4..2bf2b72 100644 --- a/integration-tests/mtx-local/srv/src/main/resources/application.yaml +++ b/integration-tests/mtx-local/srv/src/main/resources/application.yaml @@ -16,9 +16,6 @@ cds: spring: config.activate.on-profile: local-with-tenants cds: - requires: - AICore: - multiTenancy: true security: mock: tenants: diff --git a/integration-tests/spring/src/main/resources/application.yaml b/integration-tests/spring/src/main/resources/application.yaml index cefb50a..73e07c4 100644 --- a/integration-tests/spring/src/main/resources/application.yaml +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -7,8 +7,8 @@ spring: mode: always cds: - requires: - AICore: + ai: + core: resourceGroup: ${CDS_AICORE_TEST_RESOURCE_GROUP:cap-java-ai-default} maxRetries: 15 security: 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 index 8923382..8023ca2 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -3,6 +3,7 @@ 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; @@ -14,7 +15,6 @@ 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.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; import java.util.ArrayList; import java.util.HashMap; @@ -43,16 +43,14 @@ public void onReadConfigurations(CdsReadEventContext context) { @On(event = "setupTenantResources") public void onSetupTenantResources(EventContext context) { - String tenantId = (String) context.get("tenantId"); - String rgId = getAICoreService().resourceGroupForTenant(tenantId); + String rgId = getAICoreService().resourceGroup(); context.put("result", rgId); context.setCompleted(); } @On(event = "getMyResourceGroup") public void onGetMyResourceGroup(EventContext context) { - String tenant = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - String rgId = getAICoreService().resourceGroupForTenant(tenant); + String rgId = getAICoreService().resourceGroup(); context.put("result", rgId); context.setCompleted(); } @@ -139,11 +137,12 @@ public void onPredictCategory(EventContext context) { } AICoreService service = getAICoreService(); - String tenant = RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - String rg = service.resourceGroupForTenant(tenant); + String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); RptInferenceClient client = - new RptInferenceClient(service.inferenceClient(rg, deploymentId), service.getRetry()); + new RptInferenceClient( + service.inferenceClient(rg, deploymentId), + ((AbstractAICoreService) service).getRetry()); List predictions = client.predict(rows, List.of("category"), "ID"); List> results = new ArrayList<>();