diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index a37d83c..97f6520 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -25,7 +25,7 @@ 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 + run: mvn clean install -ntp -B -pl cds-feature-ai-core,cds-feature-recommendations,cds-feature-sap-document-ai,cds-starter-ai -am -DskipTests shell: bash - name: Integration Tests (spring) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java index 4a5ccd3..d20ba34 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -65,9 +65,12 @@ public void onInferenceClient(InferenceClientContext context) { "Inference client is not available without an AI Core service binding"); } - /** Resolves (or creates) the resource group name for the given tenant using the configured prefix. */ + /** + * Resolves (or creates) the resource group name for the given tenant using the configured prefix. + */ public String resolveResourceGroup(String tenantId) { - return tenantResourceGroupCache.computeIfAbsent(tenantId, id -> config.resourceGroupPrefix() + id); + return tenantResourceGroupCache.computeIfAbsent( + tenantId, id -> config.resourceGroupPrefix() + id); } /** Returns the mock tenant cache for test inspection. */ diff --git a/cds-feature-sap-document-ai/README.md b/cds-feature-sap-document-ai/README.md new file mode 100644 index 0000000..926b79c --- /dev/null +++ b/cds-feature-sap-document-ai/README.md @@ -0,0 +1,409 @@ +# SAP Document AI Plugin for SAP Cloud Application Programming Model (CAP) (Alpha Version) + +A CAP Java plugin that integrates [SAP Document AI](https://help.sap.com/docs/document-ai?locale=en-US) into CDS applications. The plugin exposes a CDS event-based API for submitting documents, manages asynchronous polling against the DIE service, and delivers results via a CDS outbound event - backed by the CDS persistent outbox for resilience across restarts. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Prerequisites](#prerequisites) +- [Integration Guide](#integration-guide) +- [Usage](#usage) + - [CDS Model](#cds-model) +- [Bookshop Sample](#bookshop-sample) + - [Running without a DIE service binding](#running-without-a-die-service-binding) + - [Running with a DIE service binding (hybrid mode)](#running-with-a-die-service-binding-hybrid-mode) +- [Configuration](#configuration) + - [DIE Service Binding](#die-service-binding) + - [Outbox](#outbox) + - [Degraded Operation](#degraded-operation) +- [Architecture Overview](docs/architecture.md) +- [Supported Plans and APIs](#supported-plans-and-apis) +- [Known Limitations](#known-limitations) +- [Monitoring and Logging](#monitoring-and-logging) +- [References](#references) +- [Support, Feedback, Contributing](#support-feedback-contributing) +- [Integration Tests](#integration-tests) + +--- + +## Quick Start + +1. Add the `sap-document-ai` Maven dependency to your application's `pom.xml`. +2. Enable the CDS persistent outbox scheduler in `application.yaml`. +3. Emit a `DocumentExtraction` event from any `ApplicationService`. +4. Implement a `DocumentExtractionResult` event handler class in your application to process the extracted data. + +For a working reference, see the [Bookshop Sample](#bookshop-sample), which demonstrates a complete integration using an in-memory database. + +--- + +## Prerequisites + +| Requirement | Minimum version | +| --------------- | --------------------------------------------------------------------- | +| Java | 17+ | +| Maven | 3.9+ | +| CAP Java | 4.9.x (LTS) | +| SAP Cloud SDK | 5.28.0+ | +| Node.js | Required only for the build-time `cds` CLI (`@sap/cds-dk`) | +| SAP BTP service | DIE service instance with label `sap-document-information-extraction` | + +All plugin dependencies are declared with `provided` scope and are available on the classpath of any standard CAP Spring Boot application. + +--- + +## Integration Guide + +This section walks through integrating the plugin into an existing CAP Java application from start to finish. + +### Step 1 - Add the dependency + +Declare the plugin in `srv/pom.xml`: + +```xml + + com.sap.cds + sap-document-ai + 1.0-SNAPSHOT + +``` + +Ensure the `cds-maven-plugin` is configured with the `resolve` goal so the plugin's CDS models are pulled into the build: + +```xml + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + cds.resolve + + resolve + + + + +``` + +### Step 2 - Enable the persistent outbox + +Add the following to `src/main/resources/application.yaml`: + +```yaml +cds: + outbox: + persistent: + scheduler: + enabled: true +``` + +Without this, documents will be submitted to DIE but results will never be retrieved. + +### Step 3 - Bind the DIE service + +**On SAP BTP (Cloud Foundry / Kubernetes):** Bind your application to a DIE service instance. The plugin discovers the binding at startup and activates extraction processing automatically. + +**For local development**, use the `cds bind` hybrid profile to forward credentials from a CF-hosted service instance: + +```bash +cf login +cds bind --to +``` + +This creates a `[hybrid]` profile entry in `.cdsrc-private.json`. Do not commit this file - it contains environment-specific binding references. Then run the application with the hybrid profile: + +```bash +cds bind --exec mvn spring-boot:run +``` + +Without a binding, the plugin starts in degraded mode - extraction events are accepted and jobs are created in `PENDING` status, but no actual processing occurs. See [Degraded Operation](#degraded-operation) for details. + +### Step 4 - Emit a DocumentExtraction event + +From any event handler or service method in your application, emit a `DocumentExtraction` event: + +```java +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; + +DocumentExtraction payload = DocumentExtraction.create(); +payload.setFileName("invoice.pdf"); +payload.setMimeType("application/pdf"); +payload.setContent(inputStream); +payload.setOptions("{\"schemaId\": \"my-schema-id\"}"); + +DocumentExtractionContext ctx = DocumentExtractionContext.create(); +ctx.setData(payload); +myApplicationService.emit(ctx); +``` + +The call returns immediately. The plugin handles submission and schedules polling asynchronously. + +### Step 5 - Handle the result + +Implement an event handler in your application to receive the extraction output once the DIE service reports the job as complete: + +```java +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; + +@ServiceName(value = "*", type = ApplicationService.class) +public class MyExtractionResultHandler implements EventHandler { + + @On(event = DocumentExtractionResultContext.CDS_NAME) + public void onExtractionComplete(DocumentExtractionResultContext context) { + DocumentExtractionResult result = context.getData(); + String jobId = result.getJobId(); + String resultJson = result.getExtractionResult(); + // process the extracted data + context.setCompleted(); + } +} +``` + +### Step 6 - Build and run + +```bash +mvn compile +mvn spring-boot:run +``` + +Submit a document via your application. The plugin logs progress at `INFO` level - look for `[sap-document-ai]` prefixed entries to trace the job from submission through to result delivery. See [Monitoring and Logging](#monitoring-and-logging) for how to enable debug-level output. + +--- + +## Usage + +> **Note:** In the current version, document extraction can only be triggered programmatically via event emission, as shown in the [Integration Guide](#integration-guide). Annotation-based triggering (e.g. declaratively marking an entity field or action to trigger extraction) is not yet supported and is planned for a future release. + +> **Note:** Multitenancy is not implemented in the current version and is planned for a future release. + +### CDS Model + +The plugin registers its CDS models automatically via the CAP plugin mechanism. No `using` declarations are required in the application model. + +The plugin exposes the service `sap.document.ai.DocumentAiService` with two events: + +| Event | Direction | Description | +| -------------------------- | ------------------------------------ | ---------------------------------------------- | +| `DocumentExtraction` | Inbound - emitted by the application | Triggers document extraction | +| `DocumentExtractionResult` | Outbound - emitted by the plugin | Delivers the extraction result upon completion | + +**`DocumentExtraction` payload:** + +| Field | Type | Description | +| ---------- | ------------- | -------------------------------------------------- | +| `fileName` | `String` | File name forwarded to the DIE service | +| `mimeType` | `String` | MIME type of the document (e.g. `application/pdf`) | +| `content` | `LargeBinary` | Document byte stream | +| `options` | `LargeString` | JSON options string passed to DIE; may be `null` | + +The `options` field maps directly to the DIE API's `options` body parameter. Refer to the [SAP Document AI's API documentation](https://help.sap.com/docs/document-ai/sap-document-ai/upload-document?locale=en-US&q=submit+document) for the full options schema. + +**`DocumentExtractionResult` payload:** + +| Field | Type | Description | +| ------------------ | ------------- | ------------------------------------------ | +| `jobId` | `String` | Plugin-internal extraction job identifier | +| `documentAiJobId` | `String` | Job identifier assigned by the DIE service | +| `extractionResult` | `LargeString` | Raw JSON extraction result returned by DIE | + +--- + +## Bookshop Sample + +The `bookshop/` directory provides a runnable reference application demonstrating the plugin integrated with the CAP Attachments plugin. + +**Prerequisites:** Java 17, Maven 3.9+, Node.js (required by the `cds` CLI invoked during the Maven build). + +### Running without a DIE service binding + +The sample can be started locally without any service binding. Extraction jobs will be created in `PENDING` status and no actual processing will occur, but the full application and UI are functional for integration exploration. + +```bash +cd bookshop/srv +mvn compile +mvn spring-boot:run +``` + +### Running with a DIE service binding (hybrid mode) + +To run the sample with a real DIE service instance, the SAP BTP Cloud Foundry environment is used via the `cds bind` hybrid profile. + +**Prerequisites:** The `@sap/cds-dk` CLI installed, and CF CLI logged in to the org and space where the DIE service instance is provisioned. + +**Step 1 - Log in to Cloud Foundry:** + +```bash +cf login +``` + +**Step 2 - Bind the DIE service instance:** + +```bash +cd bookshop +cds bind --to <> +``` + +This creates or updates `.cdsrc-private.json` with a `[hybrid]` profile entry pointing to the CF service instance and its service key. The file should not be committed to version control as it contains environment-specific binding references. + +**Step 3 - Compile and run with the hybrid profile:** + +```bash +cd bookshop +mvn compile +cds bind --exec mvn spring-boot:run +``` + +The plugin will resolve the DIE service binding at startup, construct an OAuth2-authenticated destination, and activate extraction processing. + +The `AdminService` exposes a `Books` entity with a bound action `extractDocumentData()` illustrating how to trigger extraction from a CAP action. The `Attachments` composition on `Books` provides a Fiori UI for file upload and is used here purely as a convenient way to supply documents in the sample. The CAP Attachments plugin is not a dependency of this plugin - document storage and retrieval are outside the scope of `sap-document-ai`, which is concerned solely with submitting documents to SAP Document AI and delivering the extracted results. + +--- + +## Configuration + +### DIE Service Binding + +The plugin resolves DIE credentials from the SAP BTP service binding environment at startup. It searches for a binding with the service label `sap-document-information-extraction`. + +**SAP BTP (Cloud Foundry / Kubernetes):** Bind the application to a DIE service instance by referring to [Cloud Foundry](https://help.sap.com/docs/document-ai/sap-document-ai/enabling-service-in-cloud-foundry-environment?locale=en-US&q=submit+document) or [Kuberenetes](https://help.sap.com/docs/document-ai/sap-document-ai/enabling-service-in-kyma-environment?locale=en-US&q=submit+document) documentation. The plugin discovers the binding, constructs an OAuth2-authenticated HTTP destination via the SAP Cloud SDK, and activates extraction processing. + +**Local development:** The plugin starts in degraded mode when no binding is present (see [Degraded Operation](#degraded-operation)). A local binding can be simulated via `VCAP_SERVICES` or a service binding file bearing the label `sap-document-information-extraction`. + +If the binding is present but the destination cannot be initialised (for example, due to a network or configuration error), the plugin logs a warning and disables extraction until the application is restarted. + +### Outbox + +The plugin relies on the CDS persistent outbox to schedule polling cycles. The following configuration is required in `application.yaml`: + +```yaml +cds: + outbox: + persistent: + scheduler: + enabled: true +``` + +Without the persistent outbox, documents are submitted to DIE but results are never retrieved. + +The plugin submits a polling task named `document-ai-poll-extraction-jobs` to the outbox at 3-second intervals (default) while active jobs exist. Polling stops automatically once all jobs reach a terminal status (`DONE` or `FAILED`) and resumes upon the next document submission. + +The poll interval can be configured in `application.yaml`: + +```yaml +cds: + document-ai: + polling: + interval-seconds: 3 # default +``` + +The outbox retry limit can be adjusted alongside other outbox services: + +```yaml +cds: + outbox: + services: + DefaultOutboxUnordered: + maxAttempts: 10 +``` + +### Degraded Operation + +The plugin is designed to accept events and preserve job state even when dependent services are unavailable. + +| Condition | Behaviour | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| No DIE service binding found at startup | `DocumentExtraction` events are accepted; jobs are created with status `PENDING`; polling is not scheduled | +| DIE binding present but destination initialisation fails | Same as above; a warning is logged | +| Persistent outbox not configured | Documents are submitted to DIE; the polling task is not persisted and results are not delivered | +| DIE returns a non-2xx HTTP response | The affected job is marked `FAILED`; an error is logged | +| Concurrent status update detected | The update is skipped; the later writer's state is preserved (optimistic locking) | + +--- + +## Architecture Overview + +For a detailed description of the plugin's design, component responsibilities, extraction lifecycle, and status state machine, see [here](docs/architecture.md). + +--- + +## Supported Plans and APIs + +The plugin communicates with the SAP Document Information Extraction service via its **REST API** (`document-information-extraction/v1`). This is supported across all available DIE service plans. + +| DIE Service Plan | Supported | +| ---------------- | ------------------ | +| All plans | Yes - via REST API | + +**Future:** Support for the DIE **OData API** is planned for a future release. This would enable richer query capabilities over extraction results directly through the CAP OData layer. + +--- + +## Monitoring and Logging + +All plugin log statements are prefixed with `[sap-document-ai]` to facilitate log filtering. The plugin uses SLF4J and is configured through the standard logging framework of the host application. + +| Level | Logged events | +| ------- | ------------------------------------------------------------------------------------------------------------ | +| `INFO` | Service binding resolution, job creation, status transitions, result emission | +| `WARN` | Missing binding, unavailable outbox, jobs skipped due to missing DIE job ID, concurrent update conflicts | +| `ERROR` | Submission failures, non-2xx DIE responses, polling exceptions | +| `DEBUG` | Per-cycle active job counts, DIE status poll responses, idempotent update skips, poll schedule confirmations | + +To enable debug-level logging for the plugin, add the following to `application.yaml`: + +```yaml +logging: + level: + com.sap.cds.feature.documentai: DEBUG +``` + +--- + +## References + +- [Getting Started with CAP](https://cap.cloud.sap/docs/get-started/) +- [CAP Java](https://cap.cloud.sap/docs/java/) +- [Service Consumption using Service Bindings](https://cap.cloud.sap/docs/java/cqn-services/remote-services#native-consumption) +- [Outbox](https://cap.cloud.sap/docs/java/outbox#concepts) + - [Technical Outbox API](https://cap.cloud.sap/docs/java/outbox#technical-outbox-api) +- [SAP Document AI Docs](https://help.sap.com/docs/document-ai?locale=en-US) +- [Enabling Document AI Service Instance on SAP BTP Cloud Foundry](https://help.sap.com/docs/document-ai/sap-document-ai/enabling-service-in-cloud-foundry-environment?locale=en-US) + +--- + +## Support, Feedback, Contributing + +- Bug reports and feature requests should be submitted as issues in this project repository. +- Pull requests are welcome. All contributions must pass `mvn verify`, which enforces Spotless code formatting (Google Java Format), PMD static analysis, and a minimum JaCoCo instruction coverage of 85%. + +--- + +## Integration Tests + +Spring Boot tests are implemented in the `integration-tests/` folder. The tests are executed during the build of the project in the GitHub Actions. + +The folder contains a simple Spring Boot application backed by an in-memory H2 database. No DIE service binding is required - the tests use a stub `DocumentAiClient` that returns controlled responses. + +The following scenarios are covered: + +- Plugin startup - service catalog registration and schema initialisation +- Document submission via the CAP event API +- Full extraction lifecycle (PENDING → SUBMITTED → RUNNING → DONE and FAILED paths) +- Parallel document processing in a single poll cycle +- Poll cycle resilience when one job's DIE call fails +- Graceful degradation when no DIE binding is present +- Rejection of invalid state machine transitions +- `DocumentExtractionResult` CAP event emission on job completion + +To run the tests locally, first install the plugin snapshot, then run `mvn verify` from the `integration-tests/` folder: + +```bash +cd sap-document-ai && mvn install -DskipTests +cd ../integration-tests && npm install && mvn verify +``` diff --git a/cds-feature-sap-document-ai/docs/architecture.md b/cds-feature-sap-document-ai/docs/architecture.md new file mode 100644 index 0000000..42a8943 --- /dev/null +++ b/cds-feature-sap-document-ai/docs/architecture.md @@ -0,0 +1,225 @@ +# Implementation Details + +## Table of Contents + +- [Links](#links) +- [Folder Structure](#folder-structure) +- [Feature](#feature) + - [CDS Model](#cds-model) + - [Configuration](#configuration) + - [Handlers](#handlers) + - [Services](#services) + - [Outbox and Polling](#outbox-and-polling) + - [Exceptions](#exceptions) +- [Extraction Lifecycle](#extraction-lifecycle) +- [Status State Machine](#status-state-machine) +- [Tests](#tests) + - [Unit Tests](#unit-tests) +- [Quality Tools](#quality-tools) + +--- + +## Links + +- [CAP Java Plugin Concept](https://cap.cloud.sap/docs/java/building-plugins#building-plugins) +- [CAP Java Outbox Documentation](https://cap.cloud.sap/docs/java/outbox#outboxing-cap-service-events) +- [SAP Document AI Documentation](https://help.sap.com/docs/document-ai?locale=en-US) +- [Enabling DIE Service on SAP BTP Cloud Foundry](https://help.sap.com/docs/document-ai/sap-document-ai/enabling-service-in-cloud-foundry-environment?locale=en-US) +- [CAP Java Getting Started](https://cap.cloud.sap/docs/java/getting-started) + +--- + +## Folder Structure + +| Folder | Description | +|---|---| +| `sap-document-ai` | Core implementation of the Document AI plugin | +| `sap-document-ai/src/main/java` | Java source files for handlers, services, configuration, and model classes | +| `sap-document-ai/src/main/resources/cds` | CDS model files shipped with the plugin | +| `sap-document-ai/src/main/resources/META-INF/services` | Java `ServiceLoader` registration for `CdsRuntimeConfiguration` | +| `sap-document-ai/src/test/java` | Unit tests | +| `bookshop` | Sample CAP Java application demonstrating plugin integration | +| `bookshop/srv` | Spring Boot application module for the sample | +| `bookshop/db` | CDS data model for the sample | +| `bookshop/app` | Fiori UI applications for the sample | +| `docs` | Design and architecture documentation | + +--- + +## Feature + +The plugin is implemented in the `sap-document-ai` module. The following Java packages make up the implementation: + +| Package | Description | +|---|---| +| `com.sap.cds.feature.documentai.configuration` | Bootstraps all plugin components and registers them with the CDS runtime at startup | +| `com.sap.cds.feature.documentai.handlers` | CDS event handlers for document submission and outbox-driven polling | +| `com.sap.cds.feature.documentai.service` | Core extraction service, processing service, status enum, and transition validator | +| `com.sap.cds.feature.documentai.service.client` | HTTP client abstraction for the DIE REST API | +| `com.sap.cds.feature.documentai.service.model` | Immutable value objects used as internal data transfer types | +| `com.sap.cds.feature.documentai.service.exceptions` | Typed exceptions for error classification | +| `com.sap.cds.feature.documentai.service.utils` | Utility classes | + +### CDS Model + +The CDS model is defined in: + +``` +sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/ +``` + +Per the [CAP Java plugin concept](https://cap.cloud.sap/docs/java/building-plugins#building-plugins), this path makes the model available to consuming applications via the `cds-maven-plugin` `resolve` goal. + +The model contains the following files: + +| File | Description | +|---|---| +| `document-ai-service.cds` | Defines `DocumentAiService` with the `DocumentExtraction` (inbound) and `DocumentExtractionResult` (outbound) events | +| `extraction-job.cds` | Defines the internal `ExtractionJob` entity used to persist job state across the extraction lifecycle | +| `index.cds` | Entry point that imports both files; resolved by the CAP plugin mechanism | + +The `ExtractionJob` entity uses `cuid` (auto-generated UUID primary key) and `managed` (auto-populated audit fields). It tracks the job `status`, `tenantId`, the DIE-assigned `documentAiJobId`, and the raw `extractionResult`. The table is deployed automatically as part of the consuming application's CDS schema deployment — no manual DDL is required. + +### Configuration + +`DocumentAiServiceConfiguration` implements `CdsRuntimeConfiguration` and is the plugin's sole entry point into the CDS runtime. It is discovered automatically via the Java `ServiceLoader` mechanism. + +At startup it: +- Registers `ExtractionServiceImpl` as a named CDS service in the service catalog. +- Resolves the DIE service binding from the environment by the label `sap-document-information-extraction`. +- Constructs an OAuth2-authenticated HTTP destination via the SAP Cloud SDK if a binding is found. +- Wires all resolved dependencies into `ExtractionServiceImpl`. +- Registers `DocumentSubmissionHandler` unconditionally. +- Registers `ExtractionPollingHandler` only when a valid DIE client was successfully built. + +If no binding is found or the destination cannot be initialised, the plugin starts in degraded mode — events are accepted and jobs are queued as `PENDING`, but no extraction processing occurs. + +### Handlers + +| Handler | Description | +|---|---| +| `DocumentSubmissionHandler` | Listens for `DocumentExtraction` events on any `ApplicationService`. Service-name-agnostic by design — consumers emit events from their own service without coupling to the plugin's internal service name. Delegates to `ExtractionService` and completes the event context. | +| `ExtractionPollingHandler` | Registered against the persistent unordered outbox. Polls the DIE service for all active jobs on each invocation. Self-reschedules after the configured interval if jobs remain active. Stops automatically when all jobs reach a terminal status. | + +### Services + +| Service / Class | Description | +|---|---| +| `ExtractionService` | CAP service interface registered in the service catalog. Exposes `triggerExtraction()` for new submissions and `updateExtractionResult()` for poll-driven status updates. | +| `ExtractionServiceImpl` | Central orchestrator. Creates and persists extraction jobs, coordinates submission via the processing service, schedules polling via the outbox, and enforces the status state machine on every update using optimistic locking. | +| `DocumentAiProcessingService` | Abstraction over the HTTP client. Provides an `isAvailable()` check that allows `ExtractionServiceImpl` to degrade gracefully when no DIE binding is present. | +| `DefaultDocumentAiClient` | Concrete HTTP client. Submits documents to DIE via a multipart `POST` and polls job status via `GET`. All DIE communication is authenticated via SAP Cloud SDK OAuth2 destinations. | +| `StatusTransitionValidator` | Stateless utility that enforces the permitted status transitions. Called before every status update to prevent invalid state machine transitions. | + +### Outbox and Polling + +The plugin uses the CDS **persistent unordered outbox** for all polling scheduling. This design choice means: + +- Polling is entirely **event-driven** — it runs only when there are active jobs. +- No background thread or fixed scheduler is active when the system is idle. +- Resilience across restarts is guaranteed — if the application restarts mid-poll, the outbox re-delivers the pending event automatically. +- Polling stops automatically when all jobs reach a terminal status (`DONE` or `FAILED`) and resumes when the next document is submitted. + +The poll interval defaults to 3 seconds and is configurable via `cds.document-ai.polling.interval-seconds` in `application.yaml`. + +### Exceptions + +Errors from DIE interactions are classified into three typed exceptions nested under `DocumentAiException`: + +| Exception | Condition | +|---|---| +| `DocumentAiException.Connectivity` | Network-level failure reaching DIE (timeout, DNS, etc.) | +| `DocumentAiException.Request` | Non-2xx HTTP response from DIE; carries the status code and response body | +| `DocumentAiException.Processing` | Malformed or missing fields in the DIE response | + +Two additional exceptions govern internal state management: + +| Exception | Condition | +|---|---| +| `ConcurrentJobUpdateException` | Raised when an optimistic lock update detects that a concurrent writer has already advanced the job | +| `IllegalStatusTransitionException` | Raised when a requested status transition is not permitted by the state machine | + +--- + +## Extraction Lifecycle + +``` +Application + └─ emit DocumentExtraction(fileName, mimeType, content, options) + │ + ▼ +DocumentSubmissionHandler + └─ ExtractionService.triggerExtraction() + │ + ├─ Persist ExtractionJob (status=PENDING) + │ + ├─ DIE unavailable ──► return PENDING result + │ + └─ DIE available + └─ POST multipart document to DIE + └─ receive dieJobId + └─ update job → SUBMITTED + └─ submit poll task to outbox + │ + ▼ (after configured interval, via outbox) + ExtractionPollingHandler + └─ GET DIE job status for each SUBMITTED / RUNNING job + ├─ RUNNING → update job → RUNNING, reschedule + ├─ DONE → update job → DONE + │ emit DocumentExtractionResult + │ └─ consumer @On handler invoked + └─ FAILED → update job → FAILED (terminal) +``` + +--- + +## Status State Machine + +``` +PENDING ──► SUBMITTED ──► RUNNING ──► DONE + │ │ │ + └────────►────┴────────►───┴──────► FAILED +``` + +| Transition | Trigger | +|---|---| +| `PENDING → SUBMITTED` | Document successfully submitted to DIE | +| `PENDING → FAILED` | Unrecoverable error during submission | +| `SUBMITTED → RUNNING` | DIE reports that the job is in progress | +| `SUBMITTED → DONE` | DIE reports completion without an intermediate RUNNING status | +| `SUBMITTED → FAILED` | DIE reports a processing failure | +| `RUNNING → DONE` | DIE processing completed successfully | +| `RUNNING → FAILED` | DIE reports a processing failure | + +`DONE` and `FAILED` are terminal states. No further transitions are permitted from either status. + +--- + +## Tests + +### Unit Tests + +Unit tests are located in `sap-document-ai/src/test/java`. Each production class has a corresponding test class. The following test classes are implemented: + +| Test Class | What is tested | +|---|---| +| `DocumentSubmissionHandlerTest` | Event handler delegation, PENDING and FAILED logging | +| `ExtractionServiceImplTest` | Job creation, submission flow, concurrent update handling, failure marking, outbox scheduling | +| `ExtractionPollingHandlerTest` | Poll cycle logic, DIE status mapping, result emission, self-rescheduling, per-job error isolation | +| `DefaultDocumentAiClientTest` | HTTP submit and poll calls, response parsing, error wrapping for all three exception types | +| `DocumentAiServiceConfigurationTest` | Startup wiring, binding resolution, conditional handler registration | +| `StatusTransitionValidatorTest` | All valid and invalid transitions | +| `ExceptionsTest` | Exception message and cause propagation | + +Tests use Mockito for dependencies and AssertJ for assertions. The `jacoco-maven-plugin` enforces a minimum instruction coverage of **85%** across the plugin bundle (generated code excluded). + +--- + +## Quality Tools + +| Tool | Definition | Description | +|---|---|---| +| Spotless | `sap-document-ai/pom.xml` | Enforces Google Java Format and SAP license headers on all source files | +| PMD / CPD | `sap-document-ai/pom.xml` | Static analysis and copy-paste detection; SAP Cloud SDK ruleset applied; generated code excluded | +| JaCoCo | `sap-document-ai/pom.xml` | Enforces 85% minimum instruction coverage; generated code excluded | +| Maven Compiler | `sap-document-ai/pom.xml` | Enforces Java 17 (`--release 17`) | diff --git a/cds-feature-sap-document-ai/package-lock.json b/cds-feature-sap-document-ai/package-lock.json new file mode 100644 index 0000000..8b5bf8e --- /dev/null +++ b/cds-feature-sap-document-ai/package-lock.json @@ -0,0 +1,1861 @@ +{ + "name": "cds-feature-sap-document-ai-cds", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cds-feature-sap-document-ai-cds", + "version": "1.0.0", + "devDependencies": { + "@sap/cds-dk": "9.9.1" + } + }, + "node_modules/@sap/cds-dk": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@sap/cds-dk/-/cds-dk-9.9.1.tgz", + "integrity": "sha512-cZoHI/ZhEVffmLo2k9Y/HMR5X+aGCpk60PwJJcZgoat8Kwk6dDl3mUDERhZORQUhp9FwOiyWmNujmNCV8YWWCg==", + "dev": true, + "hasShrinkwrap": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@cap-js/asyncapi": "^1.0.0", + "@cap-js/openapi": "^1.0.0", + "@sap/cds": "^8.3 || ^9", + "@sap/cds-mtxs": "^2 || ^3", + "@sap/hdi-deploy": "^5", + "express": "^4.22.1 || ^5", + "hdb": "^2.0.0", + "livereload-js": "^4.0.1", + "mustache": "^4.0.1", + "ws": "^8.4.2", + "xml-js": "^1.6.11", + "yaml": "^2" + }, + "bin": { + "cds": "bin/cds.js", + "cds-ts": "bin/cds-ts.js", + "cds-tsx": "bin/cds-tsx.js" + }, + "optionalDependencies": { + "@cap-js/sqlite": ">=1" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/asyncapi": { + "version": "1.0.3", + "integrity": "sha512-vZSWKAe+3qfvZDXV5SSFiObGWmqyS9MDyEADb5PLVT8kzO39qGaSDPv/GzI/gwvRfCayGAjU4ThiBKrFA7Gclg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/db-service": { + "version": "2.11.0", + "integrity": "sha512-sl33LcxZYAJgMCQZDw4lMGe4kWYq6685Xc6ze4qcoM+rd6aqiyVsSC6C7XH5yerXs7cVHhRC+Dgo8AsaapFzlQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "generic-pool": "^3.9.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/openapi": { + "version": "1.4.0", + "integrity": "sha512-/LRSwn4SDxAi3qKwl09zoOhEVGaPGlYOPz/0S3UBnaMJVvaLyPiKbbaOtOnrrgulUX5OXt+ujPIQznOsbTzuAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "pluralize": "^8.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=7.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/@cap-js/sqlite": { + "version": "2.4.0", + "integrity": "sha512-Ao+AzIN6BWHNpLbGxAzF79OezFNHzDG2srwiBABs0FYxIxEGkc2hg6ETo79pTTt66gcWtx7pWh/N9xk2M6SFBQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@cap-js/db-service": "^2.11.0", + "better-sqlite3": "^12.0.0" + }, + "peerDependencies": { + "@sap/cds": ">=9.8", + "sql.js": "^1.13.0" + }, + "peerDependenciesMeta": { + "sql.js": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@eslint/js": { + "version": "10.0.1", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds": { + "version": "9.9.1", + "integrity": "sha512-GqdsBsRkZThhpOyzj8ihf/jDmf/2zprZFgaun6ZymUw4/ahzjK/bbdd6eQ8txDuv88pnUl2HPFjvUVq3O/6hCA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/cds-compiler": "^6.4", + "@sap/cds-fiori": "^2", + "express": "^4.22.1 || ^5", + "yaml": "^2" + }, + "bin": { + "cds-deploy": "bin/deploy.js", + "cds-serve": "bin/serve.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@eslint/js": "^9 || ^10", + "tar": "^7.5.6" + }, + "peerDependenciesMeta": { + "tar": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-compiler": { + "version": "6.9.1", + "integrity": "sha512-j5C61t1mPhMW3vpD3LIRVn40DMiIF2XahOPeJIPjRpUiGMbQPdVreqAhiRHg39GYhSK6etlr5/MIx3a2ljtqHg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "bin": { + "cdsc": "bin/cdsc.js", + "cdshi": "bin/cdshi.js", + "cdsse": "bin/cdsse.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-fiori": { + "version": "2.3.0", + "integrity": "sha512-6oWov+DSpFrSTgxXR0dZhak6aZ/IVRZvaHERMi0EgSTzIJdlvZlpw3Kf18ePMcTrRrtEXwD4RIjKt8pbs0g2Hg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "@sap/cds": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/cds-mtxs": { + "version": "3.9.0", + "integrity": "sha512-U9H9NXQxlxSNwSD/6U59+Egn9LIE2SRdu8i5bZqEG2GB4xEU6csduy0kY4EWvi8XXD8onbFSgw4AA9SB4pN0Yg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@sap/hdi-deploy": "^5" + }, + "bin": { + "cds-mtx": "bin/cds-mtx.js", + "cds-mtx-migrate": "bin/cds-mtx-migrate.js" + }, + "peerDependencies": { + "@sap/cds": ">=9" + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi": { + "version": "4.8.0", + "integrity": "sha512-tkJmY2ffm6mt4/LFwRBihlQkMxNAXa3ngvRe2N/6+qLIsUNdrH/M03S5mkygXq56K+KoVVZYuradajCusMWwsw==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "async": "^3.2.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.5", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/hdi-deploy": { + "version": "5.6.1", + "integrity": "sha512-+qQ7qwG8lko303L5yRj2dud/nDAVuVblV/mmzJT44oPbF0Nry18eD2LUS23hFeuxjRa7rYK5YKQ8ffGgWxVrYQ==", + "dev": true, + "license": "See LICENSE file", + "dependencies": { + "@sap/hdi": "^4.8.0", + "@sap/xsenv": "^6.0.0", + "async": "^3.2.6", + "dotenv": "^16.4.5", + "handlebars": "^4.7.8", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=18.x" + }, + "peerDependencies": { + "@sap/hana-client": "^2 >= 2.6", + "hdb": "^2 || ^0" + }, + "peerDependenciesMeta": { + "@sap/hana-client": { + "optional": true + }, + "hdb": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/@sap/xsenv": { + "version": "6.2.0", + "integrity": "sha512-8jrsX1OAM3YUqGU+4deggqvkxrBrHAPYEllBX0YJfWNffgxSZKHG75bRd/RV6hxPwulPL0DeHfd2eYJMeY5gdw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE file", + "dependencies": { + "debug": "4.4.3", + "node-cache": "^5.1.2", + "verror": "1.10.1" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/accepts": { + "version": "2.0.0", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/assert-plus": { + "version": "1.0.0", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/async": { + "version": "3.2.6", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/base64-js": { + "version": "1.5.1", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/better-sqlite3": { + "version": "12.9.0", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/@sap/cds-dk/node_modules/bindings": { + "version": "1.5.0", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/bl": { + "version": "4.1.0", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/body-parser": { + "version": "2.2.2", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/braces": { + "version": "3.0.3", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/buffer": { + "version": "5.7.1", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/@sap/cds-dk/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/chownr": { + "version": "1.1.4", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/clone": { + "version": "2.1.2", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-disposition": { + "version": "1.1.0", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/cookie-signature": { + "version": "1.2.2", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/core-util-is": { + "version": "1.0.2", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/decompress-response": { + "version": "6.0.0", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/deep-extend": { + "version": "0.6.0", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/detect-libc": { + "version": "2.1.2", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/dotenv": { + "version": "16.6.1", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sap/cds-dk/node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/end-of-stream": { + "version": "1.4.5", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/es-object-atoms": { + "version": "1.1.1", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/etag": { + "version": "1.8.1", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/expand-template": { + "version": "2.0.3", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/express": { + "version": "5.2.1", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/extsprintf": { + "version": "1.4.1", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/file-uri-to-path": { + "version": "1.0.0", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/fill-range": { + "version": "7.1.1", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sap/cds-dk/node_modules/finalhandler": { + "version": "2.1.1", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/fresh": { + "version": "2.0.0", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/fs-constants": { + "version": "1.0.0", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/generic-pool": { + "version": "3.9.0", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/github-from-package": { + "version": "0.0.0", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/handlebars": { + "version": "4.7.9", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/hasown": { + "version": "2.0.3", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb": { + "version": "2.27.1", + "integrity": "sha512-xYL/W+fq2TyGHyzm8muolQnw8tdh4+2NQ8mQP2FpLSuhfJ8l0jQNSUZoAXic7NfMEan1Jvf8V1L4blwkgTc6+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "iconv-lite": "0.7.0" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "lz4-wasm-nodejs": "0.9.2" + } + }, + "node_modules/@sap/cds-dk/node_modules/hdb/node_modules/iconv-lite": { + "version": "0.7.0", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/iconv-lite": { + "version": "0.7.2", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/ieee754": { + "version": "1.2.1", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ini": { + "version": "1.3.8", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-number": { + "version": "7.0.0", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/is-promise": { + "version": "4.0.0", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/livereload-js": { + "version": "4.0.2", + "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/lz4-wasm-nodejs": { + "version": "0.9.2", + "integrity": "sha512-hSwgJPS98q/Oe/89Y1OxzeA/UdnASG8GvldRyKa7aZyoAFCC8VPRtViBSava7wWC66WocjUwBpWau2rEmyFPsw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/math-intrinsics": { + "version": "1.1.0", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/media-typer": { + "version": "1.1.0", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/merge-descriptors": { + "version": "2.0.0", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch": { + "version": "4.0.8", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/mime-types": { + "version": "3.0.2", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/mimic-response": { + "version": "3.1.0", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sap/cds-dk/node_modules/minimist": { + "version": "1.2.8", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/mkdirp-classic": { + "version": "0.5.3", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/mustache": { + "version": "4.2.0", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/@sap/cds-dk/node_modules/napi-build-utils": { + "version": "2.0.0", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/negotiator": { + "version": "1.0.0", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/neo-async": { + "version": "2.6.2", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/node-abi": { + "version": "3.92.0", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/node-cache": { + "version": "5.1.2", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/object-inspect": { + "version": "1.13.4", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/on-finished": { + "version": "2.4.1", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/once": { + "version": "1.4.0", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/@sap/cds-dk/node_modules/parseurl": { + "version": "1.3.3", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/path-to-regexp": { + "version": "8.4.2", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/pluralize": { + "version": "8.0.0", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sap/cds-dk/node_modules/prebuild-install": { + "version": "7.1.3", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/proxy-addr": { + "version": "2.0.7", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/pump": { + "version": "3.0.4", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/@sap/cds-dk/node_modules/qs": { + "version": "6.15.1", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/raw-body": { + "version": "3.0.2", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@sap/cds-dk/node_modules/rc": { + "version": "1.2.8", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/readable-stream": { + "version": "3.6.2", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sap/cds-dk/node_modules/router": { + "version": "2.2.0", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sap/cds-dk/node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/sax": { + "version": "1.6.0", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/semver": { + "version": "7.7.4", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sap/cds-dk/node_modules/send": { + "version": "1.2.1", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/serve-static": { + "version": "2.2.1", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@sap/cds-dk/node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/side-channel": { + "version": "1.1.0", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/side-channel-weakmap": { + "version": "1.0.2", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@sap/cds-dk/node_modules/simple-concat": { + "version": "1.0.1", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/simple-get": { + "version": "4.0.1", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/string_decoder": { + "version": "1.3.0", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/strip-json-comments": { + "version": "2.0.1", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-fs": { + "version": "2.1.4", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/@sap/cds-dk/node_modules/tar-stream": { + "version": "2.2.0", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sap/cds-dk/node_modules/to-regex-range": { + "version": "5.0.1", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/tunnel-agent": { + "version": "0.6.0", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@sap/cds-dk/node_modules/type-is": { + "version": "2.0.1", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sap/cds-dk/node_modules/uglify-js": { + "version": "3.19.3", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/util-deprecate": { + "version": "1.0.2", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@sap/cds-dk/node_modules/vary": { + "version": "1.1.2", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@sap/cds-dk/node_modules/verror": { + "version": "1.10.1", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@sap/cds-dk/node_modules/wordwrap": { + "version": "1.0.0", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sap/cds-dk/node_modules/wrappy": { + "version": "1.0.2", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sap/cds-dk/node_modules/ws": { + "version": "8.20.0", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@sap/cds-dk/node_modules/xml-js": { + "version": "1.6.11", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/@sap/cds-dk/node_modules/yaml": { + "version": "2.8.4", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/cds-feature-sap-document-ai/package.json b/cds-feature-sap-document-ai/package.json new file mode 100644 index 0000000..e2011fa --- /dev/null +++ b/cds-feature-sap-document-ai/package.json @@ -0,0 +1,9 @@ +{ + "name": "cds-feature-sap-document-ai-cds", + "version": "1.0.0", + "private": true, + "description": "CDS build dependencies for cds-feature-sap-document-ai. Pulled in by Maven (cds-maven-plugin npm goal) so a fresh `mvn install` is hermetic and does not require a globally installed @sap/cds-dk.", + "devDependencies": { + "@sap/cds-dk": "9.9.1" + } +} diff --git a/cds-feature-sap-document-ai/pom.xml b/cds-feature-sap-document-ai/pom.xml new file mode 100644 index 0000000..484cf19 --- /dev/null +++ b/cds-feature-sap-document-ai/pom.xml @@ -0,0 +1,155 @@ + + + 4.0.0 + + + com.sap.cds + cds-ai-root + ${revision} + + + cds-feature-sap-document-ai + jar + + CDS Feature SAP Document AI + SAP Document AI (Document Information Extraction) integration for CAP Java + + + + com.sap.cds + cds-services-api + + + + com.sap.cds + cds-services-utils + + + + com.sap.cds + cds4j-core + ${cds.services.version} + provided + + + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient5 + + + + + com.sap.cds + cds-services-impl + test + + + + org.awaitility + awaitility + 4.2.2 + test + + + + + ${project.artifactId} + + + com.sap.cds + cds-maven-plugin + + + cds.install-node + + install-node + + + + cds.npm-ci + + npm + + + ci + + + + cds.build + + cds + + + ./src/main/resources/cds/com.sap.cds/sap-document-ai + + build --for java --src ./ --dest ../../../../../../gen/srv + + + + + cds.generate + + generate + + + com.sap.cds.feature.documentai.generated.cds4j + ${project.basedir}/src/gen/srv/src/main/resources/edmx/csn.json + + sap.document.ai.** + + + + + + + + maven-clean-plugin + + + + ./ + + .flattened-pom.xml + + + + + + + auto-clean + + clean + + clean + + + + + + org.jacoco + jacoco-maven-plugin + + + **/feature/documentai/generated/** + + + + + jacoco-initialize + + prepare-agent + + + + jacoco-site-report + + report + + verify + + + + + + + diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java new file mode 100644 index 0000000..1917294 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java @@ -0,0 +1,179 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.configuration; + +import com.sap.cds.feature.documentai.handlers.DocumentSubmissionHandler; +import com.sap.cds.feature.documentai.handlers.ExtractionPollingHandler; +import com.sap.cds.feature.documentai.service.DefaultDocumentAiProcessingService; +import com.sap.cds.feature.documentai.service.DocumentAiProcessingService; +import com.sap.cds.feature.documentai.service.ExtractionServiceImpl; +import com.sap.cds.feature.documentai.service.client.DefaultDocumentAiClient; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.*; +import java.time.Duration; +import java.util.Optional; +import org.apache.hc.client5.http.classic.HttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * CDS plugin configuration that wires up all Document AI services and event handlers at runtime. + * + *

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

Responsibilities: + * + *

+ */ +public class DocumentAiServiceConfiguration implements CdsRuntimeConfiguration { + + private static final Logger logger = + LoggerFactory.getLogger(DocumentAiServiceConfiguration.class); + + private ExtractionServiceImpl extractionService; + + static { + OAuth2ServiceBindingDestinationLoader.registerPropertySupplier( + options -> + ServiceBindingUtils.matches( + options.getServiceBinding(), + DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL), + DefaultOAuth2PropertySupplier::new); + } + + /** + * Registers {@link ExtractionServiceImpl} as a CDS service so it is available in the service + * catalog for injection into event handlers. + */ + @Override + public void services(CdsRuntimeConfigurer configurer) { + extractionService = new ExtractionServiceImpl(); + configurer.service(extractionService); + } + + /** + * Resolves runtime dependencies and registers all plugin event handlers. + * + *

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

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

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

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

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

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

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

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

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

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

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

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

Orchestrates the full extraction lifecycle: + * + *

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

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

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

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

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

Two operations are provided: + * + *

+ * + *

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

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

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

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

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

Permitted transitions: + * + *

+ *   PENDING   → SUBMITTED | FAILED
+ *   SUBMITTED → RUNNING | DONE | FAILED
+ *   RUNNING   → DONE | FAILED
+ *   DONE / FAILED → (terminal, no further transitions)
+ * 
+ * + * Same-status transitions are always considered valid (idempotent updates). + */ +public class StatusTransitionValidator { + + private StatusTransitionValidator() {} + + /** + * Returns {@code true} if transitioning from {@code current} to {@code next} is permitted. + * + * @param current the status the job is currently in + * @param next the desired target status + * @return {@code true} if the transition is allowed, {@code false} otherwise + */ + public static boolean isValid(ExtractionStatus current, ExtractionStatus next) { + if (current.equals(next)) return true; // idempotent + + return switch (current) { + case PENDING -> SUBMITTED.equals(next) || FAILED.equals(next); + case SUBMITTED -> RUNNING.equals(next) || DONE.equals(next) || FAILED.equals(next); + case RUNNING -> DONE.equals(next) || FAILED.equals(next); + default -> false; + }; + } +} diff --git a/cds-feature-sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/cds-feature-sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..4e66c37 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.feature.documentai.configuration.DocumentAiServiceConfiguration diff --git a/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds new file mode 100644 index 0000000..ee2e026 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds @@ -0,0 +1,16 @@ +namespace sap.document.ai; + +service DocumentAiService { + event DocumentExtraction { + fileName : String; + mimeType : String @Core.IsMediaType; + content : LargeBinary @Core.MediaType: mimeType; + options : LargeString; + } + + event DocumentExtractionResult { + jobId : String; + documentAiJobId : String; + extractionResult : LargeString; + } +} diff --git a/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds new file mode 100644 index 0000000..bfc75af --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -0,0 +1,13 @@ +namespace sap.document.ai; + +using { + cuid, + managed +} from '@sap/cds/common'; + +entity ExtractionJob : cuid, managed { + status : String; + tenantId : String; + documentAiJobId : String; + extractionResult : LargeString; +} diff --git a/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds new file mode 100644 index 0000000..0e60ba7 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds @@ -0,0 +1,2 @@ +using from './extraction-job'; +using from './document-ai-service'; diff --git a/cds-feature-sap-document-ai/src/main/resources/spotbugs-exclusion-filter.xml b/cds-feature-sap-document-ai/src/main/resources/spotbugs-exclusion-filter.xml new file mode 100644 index 0000000..95d797b --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/spotbugs-exclusion-filter.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java new file mode 100644 index 0000000..fbfade8 --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java @@ -0,0 +1,111 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.documentai.handlers.DocumentSubmissionHandler; +import com.sap.cds.feature.documentai.service.DefaultDocumentAiProcessingService; +import com.sap.cds.feature.documentai.service.ExtractionServiceImpl; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.services.Service; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentAiServiceConfigurationTest { + + @Mock CdsRuntimeConfigurer configurer; + @Mock CdsRuntime cdsRuntime; + @Mock ServiceCatalog serviceCatalog; + @Mock PersistenceService persistenceService; + @Mock CdsEnvironment environment; + + DocumentAiServiceConfiguration registration; + + @BeforeEach + void setUp() { + registration = new DocumentAiServiceConfiguration(); + } + + @Test + void servicesRegistersExtractionService() { + registration.services(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Service.class); + verify(configurer).service(captor.capture()); + assertThat(captor.getValue()).isInstanceOf(ExtractionServiceImpl.class); + } + + @Test + void eventHandlersRegistersDocumentSubmissionHandler() { + when(configurer.getCdsRuntime()).thenReturn(cdsRuntime); + when(cdsRuntime.getServiceCatalog()).thenReturn(serviceCatalog); + when(cdsRuntime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + when(environment.getProperty( + eq("cds.document-ai.polling.interval-seconds"), eq(Integer.class), any())) + .thenReturn(3); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); + + registration.services(configurer); + registration.eventHandlers(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); + verify(configurer, times(1)).eventHandler(captor.capture()); + + assertThat(captor.getValue()).isInstanceOf(DocumentSubmissionHandler.class); + } + + @Test + void buildDocumentAi_noBindingFound_returnsNull() { + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + + DocumentAiClient result = DocumentAiServiceConfiguration.buildDocumentAi(environment); + + assertThat(result).isNull(); + } + + @Test + void buildDocumentAi_bindingFound_destinationCreationFails_returnsNull() { + ServiceBinding binding = mock(ServiceBinding.class); + when(environment.getServiceBindings()).thenReturn(Stream.of(binding)); + + try (var utils = mockStatic(ServiceBindingUtils.class); + var loader = mockStatic(ServiceBindingDestinationLoader.class)) { + utils + .when( + () -> + ServiceBindingUtils.matches( + any(), eq(DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL))) + .thenReturn(true); + + ServiceBindingDestinationLoader loaderMock = mock(ServiceBindingDestinationLoader.class); + loader.when(ServiceBindingDestinationLoader::defaultLoaderChain).thenReturn(loaderMock); + when(loaderMock.getDestination(any())).thenThrow(new RuntimeException("destination fail")); + + DocumentAiClient result = DocumentAiServiceConfiguration.buildDocumentAi(environment); + + assertThat(result).isNull(); + } + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java new file mode 100644 index 0000000..90be694 --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java @@ -0,0 +1,90 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.handlers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.services.request.UserInfo; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentSubmissionHandlerTest { + + private static final String TENANT_ID = "tenant-1"; + private static final String FILE_NAME = "invoice.pdf"; + private static final String MIME_TYPE = "application/pdf"; + + @Mock ExtractionService extractionService; + @Mock DocumentExtractionContext eventContext; + @Mock UserInfo userInfo; + + DocumentSubmissionHandler handler; + + @BeforeEach + void setUp() { + handler = new DocumentSubmissionHandler(extractionService); + } + + private DocumentExtraction createEvent() { + DocumentExtraction event = DocumentExtraction.create(); + event.setFileName(FILE_NAME); + event.setMimeType(MIME_TYPE); + event.setContent(new ByteArrayInputStream("pdf-bytes".getBytes())); + return event; + } + + @Test + void onDocumentExtraction_triggersExtraction() { + when(eventContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(eventContext.getData()).thenReturn(createEvent()); + when(extractionService.triggerExtraction(any(), any(), any(), any(), any())) + .thenReturn( + new ExtractionResult("job-123", ExtractionResult.Status.SUCCESS, "dai-job-456")); + + handler.onDocumentExtraction(eventContext); + + verify(extractionService) + .triggerExtraction( + eq(FILE_NAME), eq(MIME_TYPE), any(InputStream.class), any(), eq(TENANT_ID)); + } + + @Test + void onDocumentExtraction_logsPendingWhenServiceUnavailable() { + when(eventContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(eventContext.getData()).thenReturn(createEvent()); + when(extractionService.triggerExtraction(any(), any(), any(), any(), any())) + .thenReturn(new ExtractionResult(null, ExtractionResult.Status.PENDING, null)); + + handler.onDocumentExtraction(eventContext); + + verify(extractionService).triggerExtraction(any(), any(), any(), any(), any()); + } + + @Test + void onDocumentExtraction_logsFailedWhenExtractionFails() { + when(eventContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(eventContext.getData()).thenReturn(createEvent()); + when(extractionService.triggerExtraction(any(), any(), any(), any(), any())) + .thenReturn(new ExtractionResult("job-123", ExtractionResult.Status.FAILED, null)); + + handler.onDocumentExtraction(eventContext); + + verify(extractionService).triggerExtraction(any(), any(), any(), any(), any()); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java new file mode 100644 index 0000000..610dc8b --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java @@ -0,0 +1,185 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.handlers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.sap.cds.Result; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.outbox.OutboxMessageEventContext; +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.outbox.Schedule; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ExtractionPollingHandlerTest { + + private static final String JOB_ID = "job-123"; + private static final String DIE_JOB_ID = "die-job-456"; + private static final String RAW_RESULT = "{\"extraction\":{}}"; + + @Mock PersistenceService persistenceService; + @Mock ExtractionService extractionService; + @Mock DocumentAiClient documentAiClient; + @Mock OutboxService outboxService; + @Mock CdsRuntime runtime; + @Mock ServiceCatalog serviceCatalog; + @Mock ApplicationService documentAiService; + @Mock OutboxMessageEventContext context; + @Mock Result queryResult; + + ExtractionPollingHandler handler; + + @BeforeEach + void setUp() { + handler = + new ExtractionPollingHandler( + persistenceService, + extractionService, + documentAiClient, + outboxService, + runtime, + Duration.ofSeconds(ExtractionPollingHandler.DEFAULT_POLL_INTERVAL_SECONDS)); + } + + private void mockEmit() { + when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(serviceCatalog.getService(ApplicationService.class, DocumentAiService_.CDS_NAME)) + .thenReturn(documentAiService); + } + + @Test + void pollStopsAndSetsCompletedWhenNoActiveJobs() { + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of()); + + handler.pollExtractionJobs(context); + + verify(outboxService, never()).submit(any(), any(), any(Schedule.class)); + verify(context).setCompleted(); + } + + @Test + void pollReschedulesWhenActiveJobsExist() { + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); + + handler.pollExtractionJobs(context); + + verify(outboxService) + .submit(eq(ExtractionPollingHandler.POLL_EVENT), any(), any(Schedule.class)); + verify(context).setCompleted(); + } + + @Test + void pollSkipsJobWithNoDieJobId() { + mockActiveJob(null); + + handler.pollExtractionJobs(context); + + verify(documentAiClient, never()).getJobResult(any()); + } + + @Test + void pollDoesNotUpdateStatusWhenDieReturnsPending() { + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "PENDING", null)); + + handler.pollExtractionJobs(context); + + verify(extractionService, never()).updateExtractionResult(any(), any(), any(), any()); + } + + @Test + void pollUpdatesStatusToRunningWithoutEmittingEvent() { + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); + + handler.pollExtractionJobs(context); + + verify(extractionService) + .updateExtractionResult(JOB_ID, ExtractionStatus.RUNNING, DIE_JOB_ID, null); + verify(documentAiService, never()).emit(any()); + } + + @Test + void pollUpdatesStatusToFailedWithoutEmittingEvent() { + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "FAILED", null)); + + handler.pollExtractionJobs(context); + + verify(extractionService) + .updateExtractionResult(JOB_ID, ExtractionStatus.FAILED, DIE_JOB_ID, null); + verify(documentAiService, never()).emit(any()); + } + + @Test + void pollUpdatesStatusToDoneAndEmitsEvent() { + mockEmit(); + mockActiveJob(DIE_JOB_ID); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "DONE", RAW_RESULT)); + + handler.pollExtractionJobs(context); + + verify(extractionService) + .updateExtractionResult(JOB_ID, ExtractionStatus.DONE, DIE_JOB_ID, RAW_RESULT); + verify(documentAiService).emit(any()); + } + + @Test + void pollContinuesToNextJobWhenOneThrows() { + ExtractionJob failingJob = ExtractionJob.create(); + failingJob.setId("job-fail"); + failingJob.setDocumentAiJobId("die-fail"); + + ExtractionJob goodJob = ExtractionJob.create(); + goodJob.setId(JOB_ID); + goodJob.setDocumentAiJobId(DIE_JOB_ID); + + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of(failingJob, goodJob)); + + when(documentAiClient.getJobResult("die-fail")).thenThrow(new RuntimeException("timeout")); + when(documentAiClient.getJobResult(DIE_JOB_ID)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); + + handler.pollExtractionJobs(context); + + verify(extractionService) + .updateExtractionResult(JOB_ID, ExtractionStatus.RUNNING, DIE_JOB_ID, null); + verify(context).setCompleted(); + } + + private void mockActiveJob(String dieJobId) { + ExtractionJob job = ExtractionJob.create(); + job.setId(JOB_ID); + job.setDocumentAiJobId(dieJobId); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of(job)); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java new file mode 100644 index 0000000..212a68c --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java @@ -0,0 +1,70 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +import static org.mockito.ArgumentMatchers.any; + +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.feature.documentai.service.exceptions.DocumentAiException; +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/* + * Temporary tests until the real implementation is done + */ +class DefaultDocumentAiProcessingServiceTest { + + public static final String TEST_PDF = "test.pdf"; + public static final String CONTENT_TYPE = "application/pdf"; + public static final String TEST_CONTENT = "test"; + public static final String JOB_1 = "job-1"; + public static final String MOCK_RESULT = "mock-result"; + DocumentAiClient documentAiClient; + DefaultDocumentAiProcessingService service; + DocumentInput documentInput; + + @BeforeEach + void setUp() { + documentAiClient = Mockito.mock(DocumentAiClient.class); + Mockito.when(documentAiClient.submitDocument(any())).thenReturn(MOCK_RESULT); + service = new DefaultDocumentAiProcessingService(documentAiClient); + documentInput = + new DocumentInput( + TEST_PDF, + CONTENT_TYPE, + new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8)), + null); + } + + // ----- isAvailable() ------- + @Test + void isAvailableReturnsTrueWhenClientPresent() { + Assertions.assertThat(service.isAvailable()).isTrue(); + } + + @Test + void isAvailableReturnsFalseWhenClientNull() { + Assertions.assertThat(new DefaultDocumentAiProcessingService(null).isAvailable()).isFalse(); + } + + // ------- processDocument() ------- + @Test + void processDocumentCompletesWithoutException() { + Assertions.assertThatCode(() -> service.processDocument(JOB_1, documentInput)) + .doesNotThrowAnyException(); + } + + @Test + void processDocumentThrowsWhenSubmitDocumentFails() { + Mockito.when(documentAiClient.submitDocument(any())) + .thenThrow(new RuntimeException("submit failed")); + Assertions.assertThatThrownBy(() -> service.processDocument(JOB_1, documentInput)) + .isInstanceOf(DocumentAiException.Processing.class); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java new file mode 100644 index 0000000..3f05fad --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java @@ -0,0 +1,216 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service; + +import static com.sap.cds.feature.documentai.service.ExtractionStatus.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +import com.sap.cds.Result; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.service.exceptions.IllegalStatusTransitionException; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.ql.cqn.CqnInsert; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.services.outbox.OutboxService; +import com.sap.cds.services.outbox.Schedule; +import com.sap.cds.services.persistence.PersistenceService; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ExtractionServiceImplTest { + + static final String TENANT_1 = "tenant-1"; + static final String DIE_JOB_ID = "die-job-123"; + static final String TEST_PDF = "test.pdf"; + static final String CONTENT_TYPE = "application/pdf"; + static final String TEST_CONTENT = "test-content"; + + @Mock PersistenceService persistenceService; + @Mock DocumentAiProcessingService documentAiProcessingService; + @Mock OutboxService outboxService; + @Mock Result insertResult; + + ExtractionServiceImpl extractionService; + + @BeforeEach + void setUp() { + when(documentAiProcessingService.isAvailable()).thenReturn(true); + extractionService = new ExtractionServiceImpl(); + extractionService.init( + persistenceService, documentAiProcessingService, outboxService, Duration.ofSeconds(3)); + } + + @Test + void triggerExtractionCreatesJobAsPendingWhenServiceUnavailable() { + mockInsertDatabaseCalls(); + when(documentAiProcessingService.isAvailable()).thenReturn(false); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); + assertThat(result.internalJobId()).isNotNull(); + verify(persistenceService).run(any(CqnInsert.class)); + } + + @Test + void triggerExtractionCreatesJobWithCorrectFields() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + var insertCaptor = org.mockito.ArgumentCaptor.forClass(CqnInsert.class); + verify(persistenceService, atLeastOnce()).run(insertCaptor.capture()); + ExtractionJob inserted = + com.sap.cds.Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); + assertThat(inserted.getTenantId()).isEqualTo(TENANT_1); + assertThat(inserted.getStatus()).isEqualTo(PENDING.name()); + } + + @Test + void triggerExtractionSubmitsDocumentAndUpdatesStatusToSubmitted() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); + assertThat(result.documentAiJobId()).isEqualTo(DIE_JOB_ID); + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); + verify(outboxService).submit(any(), any(), any(Schedule.class)); + } + + @Test + void triggerExtractionDoesNotThrowWhenOutboxIsNull() { + extractionService.init( + persistenceService, documentAiProcessingService, null, Duration.ofSeconds(3)); + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); + } + + @Test + void triggerExtractionMarksJobFailedOnProcessingError() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + doThrow(new RuntimeException("simulated failure")) + .when(documentAiProcessingService) + .processDocument(any(), any()); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.FAILED); + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); + } + + @Test + void triggerExtractionReturnSuccessOnConcurrentUpdate() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + Result zeroRowResult = mock(Result.class); + when(zeroRowResult.rowCount()).thenReturn(0L); + when(persistenceService.run(any(CqnUpdate.class))).thenReturn(zeroRowResult); + + ExtractionResult result = + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); + } + + @Test + void triggerExtractionThrowsOnInvalidStatusTransition() { + mockInsertDatabaseCalls(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + doThrow(new IllegalStatusTransitionException("invalid transition")) + .when(documentAiProcessingService) + .processDocument(any(), any()); + + assertThrows( + IllegalStatusTransitionException.class, + () -> + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1)); + + verify(persistenceService, never()).run(any(CqnUpdate.class)); + } + + @Test + void updateStatusWithSameStateSkipsUpdate() { + mockInsertDatabaseCalls(); + Result statusResult = resultWithJobStatus(SUBMITTED); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); + + verify(persistenceService, never()).run(any(CqnUpdate.class)); + } + + private InputStream contentStream() { + return new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8)); + } + + private void mockInsertDatabaseCalls() { + ExtractionJob createdJob = ExtractionJob.create(); + createdJob.setId("test-job-id"); + lenient().when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); + lenient().when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); + } + + private void mockAllDatabaseCalls() { + mockInsertDatabaseCalls(); + Result updateResult = mock(Result.class); + lenient().when(updateResult.rowCount()).thenReturn(1L); + lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(updateResult); + } + + private Result resultWithJobStatus(ExtractionStatus status) { + ExtractionJob job = ExtractionJob.create(); + job.setStatus(status.name()); + Result result = mock(Result.class); + lenient().when(result.single(ExtractionJob.class)).thenReturn(job); + lenient().when(result.first(ExtractionJob.class)).thenReturn(Optional.of(job)); + return result; + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java new file mode 100644 index 0000000..9a2226c --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java @@ -0,0 +1,200 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.documentai.service.exceptions.DocumentAiException; +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DefaultDocumentAiClientTest { + + private static final String BASE_URL = "https://example.com/"; + private static final String JOB_ID = "job-abc-123"; + + @Mock HttpDestination destination; + @Mock HttpClient httpClient; + @Mock ClassicHttpResponse response; + @Mock HttpEntity entity; + + DefaultDocumentAiClient client; + DocumentInput documentInput; + + @BeforeEach + void setUp() { + client = new DefaultDocumentAiClient(destination, httpClient); + documentInput = + new DocumentInput( + "invoice.pdf", + "application/pdf", + new ByteArrayInputStream("pdf-bytes".getBytes()), + null); + when(destination.getUri()).thenReturn(URI.create(BASE_URL)); + } + + @Test + void submitDocumentReturnsJobIdOnSuccess() throws IOException { + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\"}"); + + String result = client.submitDocument(documentInput); + + assertThat(result).isEqualTo(JOB_ID); + } + + @Test + void submitDocumentThrowsRequestExceptionOnNon2xxResponse() throws IOException { + mockHttpResponse(400, "Bad Request"); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(DocumentAiException.Request.class) + .hasMessageContaining("400"); + } + + @Test + void submitDocumentThrowsConnectivityExceptionOnIoFailure() throws IOException { + when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) + .thenThrow(new IOException("timeout")); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(DocumentAiException.Connectivity.class) + .hasMessageContaining(BASE_URL); + } + + @Test + void submitDocumentThrowsWhenResponseHasNoIdField() throws IOException { + mockHttpResponse(200, "{\"status\":\"ok\"}"); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(DocumentAiException.Processing.class) + .hasMessageContaining("Unexpected DIE response"); + } + + @Test + void submitDocumentThrowsWhenResponseIsNotValidJson() throws IOException { + mockHttpResponse(200, "not-json{{{{"); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(DocumentAiException.Processing.class) + .hasMessageContaining("Failed to parse DIE response"); + } + + @Test + void getJobResultReturnsStatusAndRawBody() throws IOException { + String responseBody = "{\"id\":\"" + JOB_ID + "\",\"status\":\"DONE\",\"extraction\":{}}"; + mockHttpResponse(200, responseBody); + + ExtractionData result = client.getJobResult(JOB_ID); + + assertThat(result.dieJobId()).isEqualTo(JOB_ID); + assertThat(result.dieStatus()).isEqualTo("DONE"); + assertThat(result.rawResult()).isEqualTo(responseBody); + } + + @Test + void getJobResultThrowsWhenStatusFieldMissing() throws IOException { + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\",\"extraction\":{}}"); + + assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + .isInstanceOf(DocumentAiException.Processing.class) + .hasMessageContaining("missing 'status' field"); + } + + @Test + void getJobResultThrowsRequestExceptionOnNon2xxResponse() throws IOException { + mockHttpResponse(404, "Not Found"); + + assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + .isInstanceOf(DocumentAiException.Request.class) + .hasMessageContaining("404"); + } + + @Test + void getJobResultThrowsConnectivityExceptionOnIoFailure() throws IOException { + when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) + .thenThrow(new IOException("timeout")); + + assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + .isInstanceOf(DocumentAiException.Connectivity.class); + } + + @Test + void getJobResultThrowsWhenResponseIsNotValidJson() throws IOException { + mockHttpResponse(200, "not-json{{{{"); + + assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + .isInstanceOf(DocumentAiException.Processing.class) + .hasMessageContaining("Failed to parse DIE job result response"); + } + + @Test + void submitDocumentUsesOctetStreamWhenMimeTypeIsNull() throws IOException { + documentInput = + new DocumentInput("invoice.pdf", null, new ByteArrayInputStream("bytes".getBytes()), null); + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\"}"); + + String result = client.submitDocument(documentInput); + + assertThat(result).isEqualTo(JOB_ID); + } + + @Test + @SuppressWarnings("unchecked") + void submitDocumentUsesEmptyJsonWhenOptionsIsNull() throws IOException { + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpPost.class); + when(httpClient.execute(requestCaptor.capture(), any(HttpClientResponseHandler.class))) + .thenAnswer( + invocation -> { + HttpClientResponseHandler handler = invocation.getArgument(1); + when(response.getCode()).thenReturn(200); + when(response.getEntity()).thenReturn(entity); + when(entity.getContent()) + .thenReturn(new ByteArrayInputStream(("{\"id\":\"" + JOB_ID + "\"}").getBytes())); + when(entity.getContentLength()).thenReturn(-1L); + return handler.handleResponse(response); + }); + + client.submitDocument(documentInput); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + requestCaptor.getValue().getEntity().writeTo(buffer); + String requestBody = buffer.toString(); + assertThat(requestBody).contains("{}").contains("options"); + } + + @SuppressWarnings("unchecked") + private void mockHttpResponse(int statusCode, String body) throws IOException { + when(response.getCode()).thenReturn(statusCode); + when(response.getEntity()).thenReturn(entity); + when(entity.getContent()).thenReturn(new ByteArrayInputStream(body.getBytes())); + when(entity.getContentLength()).thenReturn(-1L); + when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) + .thenAnswer( + invocation -> { + HttpClientResponseHandler handler = invocation.getArgument(1); + return handler.handleResponse(response); + }); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java new file mode 100644 index 0000000..61a818f --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java @@ -0,0 +1,58 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.exceptions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class ExceptionsTest { + + @Test + void documentAiConnectivityExceptionContainsUrlAndCause() { + IOException cause = new IOException("connection refused"); + String url = "https://example.com/die"; + DocumentAiException.Connectivity ex = new DocumentAiException.Connectivity(url, cause); + + assertThat(ex.getMessage()).contains(url); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void documentAiProcessingExceptionContainsMessageAndCause() { + RuntimeException cause = new RuntimeException("timeout"); + String message = "Failed to process jobId=123"; + DocumentAiException.Processing ex = new DocumentAiException.Processing(message, cause); + + assertThat(ex.getMessage()).isEqualTo(message); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void documentAiRequestExceptionContainsStatusCodeAndBody() { + String badRequest = "Bad Request"; + DocumentAiException.Request ex = new DocumentAiException.Request(400, badRequest); + + assertThat(ex.getStatusCode()).isEqualTo(400); + assertThat(ex.getResponseBody()).isEqualTo(badRequest); + assertThat(ex.getMessage()).contains("400").contains(badRequest); + } + + @Test + void illegalStatusTransitionExceptionContainsMessage() { + String message = "Invalid transition from PENDING to COMPLETED"; + IllegalStatusTransitionException ex = new IllegalStatusTransitionException(message); + + assertThat(ex.getMessage()).isEqualTo(message); + } + + @Test + void concurrentJobUpdateExceptionContainsMessage() { + String message = "Concurrent update detected for jobId=abc"; + ConcurrentJobUpdateException ex = new ConcurrentJobUpdateException(message); + + assertThat(ex.getMessage()).isEqualTo(message); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java new file mode 100644 index 0000000..7e3ff9d --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java @@ -0,0 +1,67 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.service.utils; + +import static com.sap.cds.feature.documentai.service.ExtractionStatus.*; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class StatusTransitionValidatorTest { + + @Test + void pendingToSubmittedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, SUBMITTED)).isTrue(); + } + + @Test + void pendingToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, FAILED)).isTrue(); + } + + @Test + void submittedToRunningIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, RUNNING)).isTrue(); + } + + @Test + void submittedToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, FAILED)).isTrue(); + } + + @Test + void submittedToDoneIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, DONE)).isTrue(); + } + + @Test + void runningToDoneIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, DONE)).isTrue(); + } + + @Test + void runningToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, FAILED)).isTrue(); + } + + @Test + void pendingToDoneIsInvalid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, DONE)).isFalse(); + } + + @Test + void runningToPendingIsInvalid() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, PENDING)).isFalse(); + } + + @Test + void doneToRunningIsInvalid() { + Assertions.assertThat(StatusTransitionValidator.isValid(DONE, RUNNING)).isFalse(); + } + + @Test + void sameTransitionIsIdempotent() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, RUNNING)).isTrue(); + } +} diff --git a/integration-tests/mtx-local/package.json b/integration-tests/mtx-local/package.json index ccad7b8..7d5d968 100644 --- a/integration-tests/mtx-local/package.json +++ b/integration-tests/mtx-local/package.json @@ -2,6 +2,7 @@ "name": "mtx-local-integration-tests", "version": "0.0.0", "devDependencies": { + "@sap/cds": "^9", "@sap/cds-dk": "^9", "@sap/cds-mtxs": "^3" }, diff --git a/integration-tests/spring/pom.xml b/integration-tests/spring/pom.xml index 1843703..9d3a1b7 100644 --- a/integration-tests/spring/pom.xml +++ b/integration-tests/spring/pom.xml @@ -42,6 +42,11 @@ cds-feature-recommendations + + com.sap.cds + cds-feature-sap-document-ai + + com.h2database @@ -61,6 +66,13 @@ spring-security-test test + + + org.awaitility + awaitility + 4.2.2 + test + diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java new file mode 100644 index 0000000..36f2b7f --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java @@ -0,0 +1,69 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.handlers.ExtractionPollingHandler; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.client.DocumentAiClient; +import com.sap.cds.feature.documentai.service.model.DocumentInput; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cds.ql.Delete; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.outbox.OutboxMessageEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import java.time.Duration; +import java.util.function.Function; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +abstract class AbstractDocumentAiTest { + + @Autowired ServiceCatalog serviceCatalog; + @Autowired PersistenceService persistenceService; + @Autowired CdsRuntime cdsRuntime; + + @BeforeEach + @AfterEach + void resetTestData() { + persistenceService.run(Delete.from(ExtractionJob_.class)); + } + + // Executes a single polling cycle using a test DIE client that returns results supplied by the + // caller. + void runPollCycle( + ExtractionService extractionService, Function jobResultFn) { + ExtractionPollingHandler handler = + new ExtractionPollingHandler( + persistenceService, + extractionService, + pollingClient(jobResultFn), + null, + cdsRuntime, + Duration.ZERO); + + OutboxMessageEventContext ctx = + EventContext.create(OutboxMessageEventContext.class, ExtractionPollingHandler.POLL_EVENT); + handler.pollExtractionJobs(ctx); + } + + private DocumentAiClient pollingClient(Function jobResultFn) { + return new DocumentAiClient() { + @Override + public String submitDocument(DocumentInput input) { + throw new UnsupportedOperationException("Submission is not supported by this test client."); + } + + @Override + public ExtractionData getJobResult(String dieJobId) { + return jobResultFn.apply(dieJobId); + } + }; + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java new file mode 100644 index 0000000..3c6eb8f --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java @@ -0,0 +1,93 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.ql.Select; +import com.sap.cds.services.Service; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class DocumentSubmissionTest extends AbstractDocumentAiTest { + + @Autowired ExtractionService extractionService; + + @Test + void submissionWithoutDieBindingCreatesJobAsPending() { + Service documentAiService = + serviceCatalog.getService(Service.class, DocumentAiService_.CDS_NAME); + + documentAiService.emit(createExtractionContext()); + + assertThat( + persistenceService.run(Select.from(ExtractionJob_.class)).listOf(ExtractionJob.class)) + .singleElement() + .satisfies( + job -> { + assertThat(job.getStatus()).isEqualTo("PENDING"); + assertThat(job.getId()).isNotNull(); + }); + } + + @Test + void submissionStoresTenantOnJob() { + ExtractionResult submission = + extractionService.triggerExtraction( + "invoice.pdf", "application/pdf", null, null, "tenant-1"); + + ExtractionJob job = + persistenceService + .run(Select.from(ExtractionJob_.class).byId(submission.internalJobId())) + .single(ExtractionJob.class); + + assertThat(job.getStatus()).isEqualTo("PENDING"); + assertThat(job.getTenantId()).isEqualTo("tenant-1"); + } + + @Test + void jobsForDifferentTenantsAreStoredIndependently() { + String jobId1 = + extractionService + .triggerExtraction("doc.pdf", "application/pdf", null, null, "tenant-a") + .internalJobId(); + String jobId2 = + extractionService + .triggerExtraction("doc.pdf", "application/pdf", null, null, "tenant-b") + .internalJobId(); + + ExtractionJob job1 = + persistenceService + .run(Select.from(ExtractionJob_.class).byId(jobId1)) + .single(ExtractionJob.class); + ExtractionJob job2 = + persistenceService + .run(Select.from(ExtractionJob_.class).byId(jobId2)) + .single(ExtractionJob.class); + + assertThat(job1.getTenantId()).isEqualTo("tenant-a"); + assertThat(job2.getTenantId()).isEqualTo("tenant-b"); + assertThat(job1.getId()).isNotEqualTo(job2.getId()); + } + + private DocumentExtractionContext createExtractionContext() { + DocumentExtraction event = DocumentExtraction.create(); + event.setFileName("test.pdf"); + event.setMimeType("application/pdf"); + event.setContent(new ByteArrayInputStream("pdf-content".getBytes(StandardCharsets.UTF_8))); + + DocumentExtractionContext ctx = DocumentExtractionContext.create(); + ctx.setData(event); + return ctx; + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java new file mode 100644 index 0000000..ab46f87 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java @@ -0,0 +1,49 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class EventEmissionTest extends AbstractDocumentAiTest { + + private static final String DIE_JOB_ID = "die-job-emit-1"; + private static final String EXTRACTION_RESULT_JSON = "{\"invoiceNumber\":\"INV-042\"}"; + + @Autowired ExtractionService extractionService; + @Autowired ExtractionResultCaptureHandler captureHandler; + + @AfterEach + void resetCapture() { + captureHandler.reset(); + } + + @Test + void pollingHandlerEmitsDocumentExtractionResultWhenJobReachesDone() { + String jobId = + extractionService + .triggerExtraction("invoice.pdf", "application/pdf", null, null, "tenant-1") + .internalJobId(); + + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + + runPollCycle( + extractionService, + dieJobId -> new ExtractionData(dieJobId, "DONE", EXTRACTION_RESULT_JSON)); + + assertThat(captureHandler.getCaptured()) + .singleElement() + .satisfies( + captured -> { + assertThat(captured.getJobId()).isEqualTo(jobId); + assertThat(captured.getExtractionResult()).isEqualTo(EXTRACTION_RESULT_JSON); + }); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java new file mode 100644 index 0000000..6ca8d7c --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java @@ -0,0 +1,39 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.exceptions.IllegalStatusTransitionException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ExtractionErrorTest extends AbstractDocumentAiTest { + + private static final String DIE_JOB_ID = "die-1"; + + @Autowired ExtractionService extractionService; + + @Test + void invalidStatusTransitionThrows() { + String jobId = + extractionService + .triggerExtraction("invoice.pdf", "application/pdf", null, null, "tenant-1") + .internalJobId(); + + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + extractionService.updateExtractionResult( + jobId, ExtractionStatus.DONE, DIE_JOB_ID, "{\"result\":\"ok\"}"); + + assertThatThrownBy( + () -> + extractionService.updateExtractionResult( + jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null)) + .isInstanceOf(IllegalStatusTransitionException.class) + .hasMessageContaining(ExtractionStatus.DONE.name()) + .hasMessageContaining(ExtractionStatus.SUBMITTED.name()); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java new file mode 100644 index 0000000..8cc0017 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java @@ -0,0 +1,153 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.model.ExtractionData; +import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.ql.Select; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ExtractionLifecycleTest extends AbstractDocumentAiTest { + + private static final String DIE_JOB_ID = "die-job-1"; + private static final String EXTRACTION_RESULT_JSON = "{\"invoiceNumber\":\"INV-001\"}"; + + @Autowired ExtractionService extractionService; + @Autowired ExtractionResultCaptureHandler captureHandler; + + @AfterEach + void resetCapture() { + captureHandler.reset(); + } + + @Test + void jobAdvancesThroughLifecycleToDone() { + ExtractionResult submission = submit("invoice.pdf"); + String jobId = submission.internalJobId(); + assertThat(submission.status()).isEqualTo(ExtractionResult.Status.PENDING); + + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + extractionService.updateExtractionResult(jobId, ExtractionStatus.RUNNING, DIE_JOB_ID, null); + extractionService.updateExtractionResult( + jobId, ExtractionStatus.DONE, DIE_JOB_ID, EXTRACTION_RESULT_JSON); + + ExtractionJob job = job(jobId); + assertThat(job.getStatus()).isEqualTo(ExtractionStatus.DONE.name()); + assertThat(job.getDocumentAiJobId()).isEqualTo(DIE_JOB_ID); + assertThat(job.getExtractionResult()).isEqualTo(EXTRACTION_RESULT_JSON); + } + + @Test + void jobCanTransitionToFailed() { + String jobId = submit("bad.pdf").internalJobId(); + + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + extractionService.updateExtractionResult(jobId, ExtractionStatus.FAILED, DIE_JOB_ID, null); + + ExtractionJob job = job(jobId); + assertThat(job.getStatus()).isEqualTo(ExtractionStatus.FAILED.name()); + assertThat(job.getDocumentAiJobId()).isEqualTo(DIE_JOB_ID); + } + + @Test + void singleDocumentFullRoundTripViaPollCycle() { + String jobId = submit("invoice.pdf").internalJobId(); + extractionService.updateExtractionResult(jobId, ExtractionStatus.SUBMITTED, DIE_JOB_ID, null); + + runPollCycle( + extractionService, + dieJobId -> new ExtractionData(dieJobId, "DONE", EXTRACTION_RESULT_JSON)); + + ExtractionJob job = job(jobId); + assertThat(job.getStatus()).isEqualTo(ExtractionStatus.DONE.name()); + assertThat(job.getExtractionResult()).isEqualTo(EXTRACTION_RESULT_JSON); + assertThat(captureHandler.getCaptured()) + .singleElement() + .satisfies( + event -> { + assertThat(event.getJobId()).isEqualTo(jobId); + assertThat(event.getExtractionResult()).isEqualTo(EXTRACTION_RESULT_JSON); + }); + } + + @Test + void twoParallelDocumentsReachDoneIndependently() { + String dieJobId1 = "die-job-parallel-1"; + String dieJobId2 = "die-job-parallel-2"; + String result1 = "{\"invoiceNumber\":\"INV-A\"}"; + String result2 = "{\"invoiceNumber\":\"INV-B\"}"; + + String jobId1 = submit("doc-a.pdf").internalJobId(); + String jobId2 = submit("doc-b.pdf").internalJobId(); + + extractionService.updateExtractionResult(jobId1, ExtractionStatus.SUBMITTED, dieJobId1, null); + extractionService.updateExtractionResult(jobId2, ExtractionStatus.SUBMITTED, dieJobId2, null); + + Map resultsByDieJobId = Map.of(dieJobId1, result1, dieJobId2, result2); + runPollCycle( + extractionService, + dieJobId -> new ExtractionData(dieJobId, "DONE", resultsByDieJobId.get(dieJobId))); + + List jobs = + persistenceService.run(Select.from(ExtractionJob_.class)).listOf(ExtractionJob.class); + assertThat(jobs) + .extracting(ExtractionJob::getStatus) + .containsOnly(ExtractionStatus.DONE.name()); + + assertThat(job(jobId1).getExtractionResult()).isEqualTo(result1); + assertThat(job(jobId2).getExtractionResult()).isEqualTo(result2); + + assertThat(captureHandler.getCaptured()) + .extracting(DocumentExtractionResult::getJobId) + .containsExactlyInAnyOrder(jobId1, jobId2); + } + + @Test + void pollCycleContinuesWhenOneJobFails() { + // one polling request fails mid-cycle — the other job must still reach DONE + String dieJobIdA = "die-job-ok"; + String dieJobIdB = "die-job-error"; + + String jobIdA = submit("doc-a.pdf").internalJobId(); + String jobIdB = submit("doc-b.pdf").internalJobId(); + + extractionService.updateExtractionResult(jobIdA, ExtractionStatus.SUBMITTED, dieJobIdA, null); + extractionService.updateExtractionResult(jobIdB, ExtractionStatus.SUBMITTED, dieJobIdB, null); + + runPollCycle( + extractionService, + dieJobId -> { + if (dieJobId.equals(dieJobIdB)) throw new RuntimeException("simulated DIE failure"); + return new ExtractionData(dieJobId, "DONE", EXTRACTION_RESULT_JSON); + }); + + assertThat(job(jobIdA).getStatus()).isEqualTo(ExtractionStatus.DONE.name()); + assertThat(job(jobIdB).getStatus()).isEqualTo(ExtractionStatus.SUBMITTED.name()); + + assertThat(captureHandler.getCaptured()) + .singleElement() + .satisfies(event -> assertThat(event.getJobId()).isEqualTo(jobIdA)); + } + + private ExtractionResult submit(String fileName) { + return extractionService.triggerExtraction(fileName, "application/pdf", null, null, "tenant-1"); + } + + private ExtractionJob job(String jobId) { + return persistenceService + .run(Select.from(ExtractionJob_.class).byId(jobId)) + .single(ExtractionJob.class); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java new file mode 100644 index 0000000..b9a4283 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java @@ -0,0 +1,36 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +@ServiceName( + value = DocumentAiService_.CDS_NAME, + type = com.sap.cds.services.cds.ApplicationService.class) +class ExtractionResultCaptureHandler implements EventHandler { + + private final List captured = new ArrayList<>(); + + @After(event = DocumentExtractionResultContext.CDS_NAME) + public void onExtractionResult(DocumentExtractionResultContext context) { + captured.add(context.getData()); + } + + public List getCaptured() { + return List.copyOf(captured); + } + + public void reset() { + captured.clear(); + } +} diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java new file mode 100644 index 0000000..5f861b6 --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java @@ -0,0 +1,27 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.ql.Select; +import com.sap.cds.services.Service; +import org.junit.jupiter.api.Test; + +class PluginLoadTest extends AbstractDocumentAiTest { + + @Test + void documentAiServiceIsRegisteredInCatalog() { + Service documentAiService = + serviceCatalog.getService(Service.class, DocumentAiService_.CDS_NAME); + assertThat(documentAiService).isNotNull(); + } + + @Test + void extractionJobTableIsAccessible() { + persistenceService.run(Select.from(ExtractionJob_.class).columns(ExtractionJob_::ID)); + } +} diff --git a/integration-tests/spring/test-service.cds b/integration-tests/spring/test-service.cds index 232f698..f14b2c1 100644 --- a/integration-tests/spring/test-service.cds +++ b/integration-tests/spring/test-service.cds @@ -1,5 +1,6 @@ using {itest} from '../db/schema'; using { AICore } from 'com.sap.cds/ai'; +using from 'com.sap.cds/sap-document-ai'; service TestService { entity Products as projection on itest.Products; diff --git a/pom.xml b/pom.xml index 5dadf74..75a5bc7 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,7 @@ cds-feature-ai-core cds-feature-recommendations + cds-feature-sap-document-ai cds-starter-ai @@ -165,6 +166,12 @@ ${revision} + + com.sap.cds + cds-feature-sap-document-ai + ${revision} + + com.sap.cds cds-feature-ai-integration-tests-spring diff --git a/samples/document-ai-bookshop/.cdsrc.json b/samples/document-ai-bookshop/.cdsrc.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/samples/document-ai-bookshop/.cdsrc.json @@ -0,0 +1,2 @@ +{ +} diff --git a/samples/document-ai-bookshop/.gitignore b/samples/document-ai-bookshop/.gitignore new file mode 100644 index 0000000..2ecc68d --- /dev/null +++ b/samples/document-ai-bookshop/.gitignore @@ -0,0 +1,34 @@ +**/gen/ +**/edmx/ +*.db +*.sqlite +*.sqlite-wal +*.sqlite-shm +schema*.sql +default-env.json + +**/bin/ +**/target/ +.flattened-pom.xml +.classpath +.project +.settings + +**/node/ +**/node_modules/ + +**/.mta/ +*.mtar + +*.log* +gc_history* +hs_err* +*.tgz +*.iml + +.vscode +.idea +.reloadtrigger + +# added by cds +.cdsrc-private.json diff --git a/samples/document-ai-bookshop/app/_i18n/i18n.properties b/samples/document-ai-bookshop/app/_i18n/i18n.properties new file mode 100644 index 0000000..7326bbb --- /dev/null +++ b/samples/document-ai-bookshop/app/_i18n/i18n.properties @@ -0,0 +1,15 @@ +Books = Books +Book = Book +ID = ID +Title = Title +Author = Author +Authors = Authors +AuthorID = Author ID +AuthorName = Author Name +Name = Name +Age = Age +Stock = Stock +Order = Order +Orders = Orders +Price = Price +Genre = Genre \ No newline at end of file diff --git a/samples/document-ai-bookshop/app/_i18n/i18n_de.properties b/samples/document-ai-bookshop/app/_i18n/i18n_de.properties new file mode 100644 index 0000000..cb712c1 --- /dev/null +++ b/samples/document-ai-bookshop/app/_i18n/i18n_de.properties @@ -0,0 +1,15 @@ +Books = Bücher +Book = Buch +ID = ID +Title = Titel +Author = Autor +Authors = Autoren +AuthorID = ID des Autors +AuthorName = Name des Autors +Name = Name +Age = Alter +Stock = Bestand +Order = Bestellung +Orders = Bestellungen +Price = Preis +Genre = Genre \ No newline at end of file diff --git a/samples/document-ai-bookshop/app/admin-books/fiori-service.cds b/samples/document-ai-bookshop/app/admin-books/fiori-service.cds new file mode 100644 index 0000000..0bff5da --- /dev/null +++ b/samples/document-ai-bookshop/app/admin-books/fiori-service.cds @@ -0,0 +1,128 @@ +using {AdminService} from '../../srv/admin-service.cds'; + +//////////////////////////////////////////////////////////////////////////// +// +// Books Object Page +// +annotate AdminService.Books with @(UI: { + HeaderInfo : { + TypeName : '{i18n>Book}', + TypeNamePlural: '{i18n>Books}', + Title : {Value: title}, + Description : {Value: author.name} + }, + Facets : [ + { + $Type : 'UI.ReferenceFacet', + Label : '{i18n>General}', + Target: '@UI.FieldGroup#General' + }, + { + $Type : 'UI.ReferenceFacet', + Label : '{i18n>Translations}', + Target: 'texts/@UI.LineItem' + }, + { + $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} + ]}, + 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; + +annotate AdminService.Books.texts with @(UI: { + Identification : [{Value: title}], + SelectionFields: [ + locale, + title + ], + LineItem : [ + { + Value: locale, + Label: 'Locale' + }, + { + Value: title, + Label: 'Title' + }, + { + Value: descr, + Label: 'Description' + } + ] +}); + +annotate AdminService.Books.texts with { + ID @UI.Hidden; + ID_texts @UI.Hidden; +}; + +// Add Value Help for Locales +annotate AdminService.Books.texts { + locale @( + ValueList.entity: 'Languages', + Common.ValueListWithFixedValues //show as drop down, not a dialog + ) +}; + +// 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; +} + +//////////////////////////////////////////////////////////////////////////// +// +// Document AI - Upload Section +// + +annotate AdminService.Books with @( + UI.Identification : [ + { + $Type : 'UI.DataFieldForAction', + Action : 'AdminService.extractDocumentData', + Label : 'Extract Document Data' + } + ] +); \ No newline at end of file diff --git a/samples/document-ai-bookshop/app/admin-books/webapp/Component.js b/samples/document-ai-bookshop/app/admin-books/webapp/Component.js new file mode 100644 index 0000000..e98677e --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/admin-books/webapp/i18n/i18n.properties b/samples/document-ai-bookshop/app/admin-books/webapp/i18n/i18n.properties new file mode 100644 index 0000000..9a23ee4 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/admin-books/webapp/i18n/i18n_de.properties b/samples/document-ai-bookshop/app/admin-books/webapp/i18n/i18n_de.properties new file mode 100644 index 0000000..01d56a2 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/admin-books/webapp/manifest.json b/samples/document-ai-bookshop/app/admin-books/webapp/manifest.json new file mode 100644 index 0000000..4bcc54c --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/appconfig/fioriSandboxConfig.json b/samples/document-ai-bookshop/app/appconfig/fioriSandboxConfig.json new file mode 100644 index 0000000..ff2ac49 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/browse/fiori-service.cds b/samples/document-ai-bookshop/app/browse/fiori-service.cds new file mode 100644 index 0000000..b49a94f --- /dev/null +++ b/samples/document-ai-bookshop/app/browse/fiori-service.cds @@ -0,0 +1,51 @@ +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} + }, + HeaderFacets : [{ + $Type : 'UI.ReferenceFacet', + Label : '{i18n>Description}', + Target: '@UI.FieldGroup#Descr' + }], + Facets : [{ + $Type : 'UI.ReferenceFacet', + Label : '{i18n>Details}', + Target: '@UI.FieldGroup#Price' + }], + FieldGroup #Descr: {Data: [{Value: descr}]}, + FieldGroup #Price: {Data: [{Value: price}]} +}); + +//////////////////////////////////////////////////////////////////////////// +// +// 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.symbol} + ] +}); diff --git a/samples/document-ai-bookshop/app/browse/webapp/Component.js b/samples/document-ai-bookshop/app/browse/webapp/Component.js new file mode 100644 index 0000000..4020679 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/browse/webapp/i18n/i18n.properties b/samples/document-ai-bookshop/app/browse/webapp/i18n/i18n.properties new file mode 100644 index 0000000..21436e8 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/browse/webapp/i18n/i18n_de.properties b/samples/document-ai-bookshop/app/browse/webapp/i18n/i18n_de.properties new file mode 100644 index 0000000..ea86c3f --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/browse/webapp/manifest.json b/samples/document-ai-bookshop/app/browse/webapp/manifest.json new file mode 100644 index 0000000..cd4b1c3 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/app/common.cds b/samples/document-ai-bookshop/app/common.cds new file mode 100644 index 0000000..69627be --- /dev/null +++ b/samples/document-ai-bookshop/app/common.cds @@ -0,0 +1,264 @@ +/* + 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}, + {Value: currency.symbol} + ] + } +) { + 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}' @Measures.ISOCurrency: currency_code; + stock @title: '{i18n>Stock}'; + descr @title: '{i18n>Description}' @UI.MultiLineText; + image @title: '{i18n>Image}'; +} + +//////////////////////////////////////////////////////////////////////////// +// +// 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/document-ai-bookshop/app/index.html b/samples/document-ai-bookshop/app/index.html new file mode 100644 index 0000000..70f6315 --- /dev/null +++ b/samples/document-ai-bookshop/app/index.html @@ -0,0 +1,32 @@ + + + + + + + + Bookshop + + + + + + + + + + diff --git a/samples/document-ai-bookshop/app/services.cds b/samples/document-ai-bookshop/app/services.cds new file mode 100644 index 0000000..87e7b31 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/db/data/sap.capire.bookshop-Authors.csv b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Authors.csv new file mode 100644 index 0000000..5272ee1 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/db/data/sap.capire.bookshop-Books.csv b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books.csv new file mode 100644 index 0000000..46d63fa --- /dev/null +++ b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books.csv @@ -0,0 +1,6 @@ +ID;title;descr;author_ID;stock;price;currency_code;genre_ID +aeeda49f-72f2-4880-be27-a513b2e53040;Wuthering Heights;"Wuthering Heights, Emily Brontë's only novel, was published in 1847 under the pseudonym ""Ellis Bell"". It was written between October 1845 and June 1846. Wuthering Heights and Anne Brontë's Agnes Grey were accepted by publisher Thomas Newby before the success of their sister Charlotte's novel Jane Eyre. After Emily's death, Charlotte edited the manuscript of Wuthering Heights and arranged for the edited version to be published as a posthumous second edition in 1850.";10fef92e-975f-4c41-8045-c58e5c27a040;12;11.11;GBP;11 +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;13 diff --git a/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books_texts.csv b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books_texts.csv new file mode 100644 index 0000000..3a3465b --- /dev/null +++ b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books_texts.csv @@ -0,0 +1,5 @@ +ID_texts;ID;locale;title;descr +52eee553-266d-4fdd-a5ca-909910e76ae4;aeeda49f-72f2-4880-be27-a513b2e53040;de;Sturmhöhe;Sturmhöhe (Originaltitel: Wuthering Heights) ist der einzige Roman der englischen Schriftstellerin Emily Brontë (1818–1848). Der 1847 unter dem Pseudonym Ellis Bell veröffentlichte Roman wurde vom viktorianischen Publikum weitgehend abgelehnt, heute gilt er als ein Klassiker der britischen Romanliteratur des 19. Jahrhunderts. +54e58142-f06e-49c1-a51d-138f86cea34e;aeeda49f-72f2-4880-be27-a513b2e53040;fr;Les Hauts de Hurlevent;Les Hauts de Hurlevent (titre original : Wuthering Heights), parfois orthographié Les Hauts de Hurle-Vent, est l'unique roman d'Emily Brontë, publié pour la première fois en 1847 sous le pseudonyme d’Ellis Bell. Loin d'être un récit moralisateur, Emily Brontë achève néanmoins le roman dans une atmosphère sereine, suggérant le triomphe de la paix et du Bien sur la vengeance et le Mal. +bbbf8a88-797d-4790-af1c-1cc857718ee0;b0056977-4cf5-46a2-ab14-6409ee2e0df1;de;Jane Eyre;Jane Eyre. Eine Autobiographie (Originaltitel: Jane Eyre. An Autobiography), erstmals erschienen im Jahr 1847 unter dem Pseudonym Currer Bell, ist der erste veröffentlichte Roman der britischen Autorin Charlotte Brontë und ein Klassiker der viktorianischen Romanliteratur des 19. Jahrhunderts. Der Roman erzählt in Form einer Ich-Erzählung die Lebensgeschichte von Jane Eyre (ausgesprochen /ˌdʒeɪn ˈɛə/), die nach einer schweren Kindheit eine Stelle als Gouvernante annimmt und sich in ihren Arbeitgeber verliebt, jedoch immer wieder um ihre Freiheit und Selbstbestimmung kämpfen muss. Als klein, dünn, blass, stets schlicht dunkel gekleidet und mit strengem Mittelscheitel beschrieben, gilt die Heldin des Romans Jane Eyre nicht zuletzt aufgrund der Kino- und Fernsehversionen der melodramatischen Romanvorlage als die bekannteste englische Gouvernante der Literaturgeschichte +a90d4378-1a3e-48e7-b60b-5670e78807e1;7756b725-cefc-43a2-a3c8-0c9104a349b8;de;Eleonora;“Eleonora” ist eine Erzählung von Edgar Allan Poe. Sie wurde 1841 erstveröffentlicht. In ihr geht es um das Paradox der Treue in der Treulosigkeit. diff --git a/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Genres.csv b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Genres.csv new file mode 100644 index 0000000..1ea3793 --- /dev/null +++ b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Genres.csv @@ -0,0 +1,16 @@ +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/document-ai-bookshop/db/schema.cds b/samples/document-ai-bookshop/db/schema.cds new file mode 100644 index 0000000..1aedfba --- /dev/null +++ b/samples/document-ai-bookshop/db/schema.cds @@ -0,0 +1,37 @@ +using { + Currency, + managed, + cuid, + sap.common.CodeList +} from '@sap/cds/common'; + +namespace sap.capire.bookshop; + +entity Books : managed, cuid { + @mandatory title : localized String(111); + descr : localized String(1111); + @mandatory author : Association to Authors; + genre : Association to Genres; + stock : Integer; + price : Decimal; + currency : Currency; + image : LargeBinary @Core.MediaType: 'image/png'; +} + +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 */ +entity Genres : CodeList { + key ID : Integer; + parent : Association to Genres; + children : Composition of many Genres + on children.parent = $self; +} diff --git a/samples/document-ai-bookshop/package-lock.json b/samples/document-ai-bookshop/package-lock.json new file mode 100644 index 0000000..336e3c9 --- /dev/null +++ b/samples/document-ai-bookshop/package-lock.json @@ -0,0 +1,1862 @@ +{ + "name": "bookshop-cds", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bookshop-cds", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@sap/cds-dk": "^9.5.0" + } + }, + "node_modules/@sap/cds-dk": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@sap/cds-dk/-/cds-dk-9.9.0.tgz", + "integrity": "sha512-gl2BOLhkyw9yFlNnX4ukl6dNEYDX7hVg+7QFUqlFTxcBU+tdQlFT8szk3NK77DkJkj1Xe+LZLJSe0crTtA32mw==", + "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", + "@sap/cds-mtxs": ">=2", + "@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.9.0", + "integrity": "sha512-WCXhoqezaF6A5I2l0MNZeHKXXtHRNEq7Rp0R89/uccOHQIx0DuU0U9NuJJPV/1G5RGk2QKQ9VBo/KYn+MZuuNQ==", + "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.2.0", + "integrity": "sha512-FPj+uVU/14vtGUl2P/Q8y7XhZbsLgrCav2O5PjHPXnupegjby4sMJkgVNxVHnkyKPFgO/W8uEsq9r5TU9VPx8w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@cap-js/db-service": "^2.9.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.0", + "integrity": "sha512-09qkye9erBr71GNt6vdu9HmvZCKSTECYdxppQyAYJjeOeLJW4eL1eyZThawRkvqqLOEdmsEZdfG6eZ3X+w0YYA==", + "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.0", + "integrity": "sha512-UXQ5pomb+Fw48Ch1mJoN1JkDy0loZX8nZKXjy4qxY2s9FMwNOwKJP9wPomAVlYcuzq6u8Viqh5j70ty8ciGWGg==", + "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.89.0", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "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.3", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/samples/document-ai-bookshop/package.json b/samples/document-ai-bookshop/package.json new file mode 100644 index 0000000..aea6874 --- /dev/null +++ b/samples/document-ai-bookshop/package.json @@ -0,0 +1,17 @@ +{ + "name": "bookshop-cds", + "version": "1.0.0", + "description": "Generated by cds-services-archetype", + "license": "ISC", + "repository": "", + "devDependencies": { + "@sap/cds-dk": "^9.5.0" + }, + "cds": { + "requires": { + "com.sap.cds/sap-document-ai": { + "model": "com.sap.cds/sap-document-ai" + } + } + } +} diff --git a/samples/document-ai-bookshop/pom.xml b/samples/document-ai-bookshop/pom.xml new file mode 100644 index 0000000..38b520e --- /dev/null +++ b/samples/document-ai-bookshop/pom.xml @@ -0,0 +1,159 @@ + + + 4.0.0 + + customer + bookshop-parent + ${revision} + pom + + bookshop parent + + + + 1.0.0-SNAPSHOT + + + 17 + 4.9.0 + 3.5.8 + 0.0.1-alpha + + https://nodejs.org/dist/ + UTF-8 + + + + srv + + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + com.sap.cds + cds-feature-sap-document-ai + ${cds-feature-sap-document-ai.version} + + + + com.sap.cds + cds-feature-attachments + 1.5.0 + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + + + + 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/document-ai-bookshop/srv/admin-service.cds b/samples/document-ai-bookshop/srv/admin-service.cds new file mode 100644 index 0000000..540f5ba --- /dev/null +++ b/samples/document-ai-bookshop/srv/admin-service.cds @@ -0,0 +1,10 @@ +using {sap.capire.bookshop as my} from '../db/schema'; + +service AdminService @(requires: 'admin') { + + entity Books as projection on my.Books actions { + action extractDocumentData() returns Boolean; + }; + + entity Authors as projection on my.Authors; +} \ No newline at end of file diff --git a/samples/document-ai-bookshop/srv/attachments.cds b/samples/document-ai-bookshop/srv/attachments.cds new file mode 100644 index 0000000..69a1717 --- /dev/null +++ b/samples/document-ai-bookshop/srv/attachments.cds @@ -0,0 +1,30 @@ +using {sap.capire.bookshop as my} from '../db/schema'; +using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; + +extend my.Books with { + attachments : Composition of many Attachments; +} + +// Add UI component for attachments table to the Browse Books App +using {CatalogService as service} from '../app/services'; + +annotate service.Books with @(UI.Facets: [{ + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : '{i18n>attachments}', + Target: 'attachments/@UI.LineItem' +}]); + +// Adding the UI Component (a table) to the Administrator App +using {AdminService as adminService} from '../app/services'; + +annotate adminService.Books with @(UI.Facets: [{ + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : '{i18n>attachments}', + Target: 'attachments/@UI.LineItem' +}]); + +service nonDraftService { + entity Books as projection on my.Books; +} diff --git a/samples/document-ai-bookshop/srv/cat-service.cds b/samples/document-ai-bookshop/srv/cat-service.cds new file mode 100644 index 0000000..1d2cbba --- /dev/null +++ b/samples/document-ai-bookshop/srv/cat-service.cds @@ -0,0 +1,34 @@ +using {sap.capire.bookshop as my} from '../db/schema'; + +service CatalogService { + + /** 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/document-ai-bookshop/srv/pom.xml b/samples/document-ai-bookshop/srv/pom.xml new file mode 100644 index 0000000..51a8279 --- /dev/null +++ b/samples/document-ai-bookshop/srv/pom.xml @@ -0,0 +1,137 @@ + + 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-sap-document-ai + + + + com.sap.cds + cds-feature-attachments + + + + + ${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.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/document-ai-bookshop/srv/src/main/java/customer/bookshop/Application.java b/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/Application.java new file mode 100644 index 0000000..f395d21 --- /dev/null +++ b/samples/document-ai-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/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java b/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java new file mode 100644 index 0000000..158e2be --- /dev/null +++ b/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java @@ -0,0 +1,67 @@ +package customer.bookshop.handlers; + +import static cds.gen.catalogservice.CatalogService_.BOOKS; + +import java.util.stream.Stream; + +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.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 { + + private final PersistenceService db; + + public CatalogServiceHandler(PersistenceService db) { + this.db = db; + } + + @On + public ReturnType submitOrder(SubmitOrderContext context) { + // decrease and update stock in database + db.run(Update.entity(BOOKS).byId(context.getBook()).set(b -> b.stock(), s -> s.minus(context.getQuantity()))); + + // read new stock from database + Books book = db.run(Select.from(BOOKS).where(b -> b.ID().eq(context.getBook()))).single(); + + // publish event + 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); + + // return new stock to client + ReturnType result = SubmitOrderContext.ReturnType.create(); + result.setStock(book.getStock()); + + return 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/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java b/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java new file mode 100644 index 0000000..241ef79 --- /dev/null +++ b/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java @@ -0,0 +1,134 @@ +package customer.bookshop.handlers; + +import cds.gen.adminservice.AdminService_; +import cds.gen.adminservice.Books_; +import cds.gen.adminservice.BooksAttachments; +import cds.gen.adminservice.BooksAttachments_; +import cds.gen.adminservice.BooksDraftActivateContext; +import cds.gen.adminservice.BooksExtractDocumentDataContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ErrorStatuses;import com.sap.cds.services.Service; +import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.draft.DraftService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +@Component +@ServiceName(AdminService_.CDS_NAME) +public class DocumentExtractionHandler implements EventHandler { + + private static final Logger logger = + LoggerFactory.getLogger(DocumentExtractionHandler.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final DraftService adminService; + private final CdsModel cdsModel; + private final ServiceCatalog serviceCatalog; + + public DocumentExtractionHandler( + @Qualifier(AdminService_.CDS_NAME) DraftService adminService, + CdsModel cdsModel, + ServiceCatalog serviceCatalog) { + this.adminService = adminService; + this.cdsModel = cdsModel; + this.serviceCatalog = serviceCatalog; + } + + @Before(event = BooksDraftActivateContext.CDS_NAME, entity = Books_.CDS_NAME) + public void beforeDraftActivate(BooksDraftActivateContext context) { + String bookId = (String) CqnAnalyzer.create(cdsModel) + .analyze(context.getCqn().ref()) + .rootKeys() + .get(Books_.ID); + if (bookId == null) return; + + long count = adminService.run( + Select.from(BooksAttachments_.class) + .columns(b -> b.ID()) + .where(b -> b.up__ID().eq(bookId).and(b.IsActiveEntity().eq(false))) + ).rowCount(); + + logger.info("[DocumentExtractionHandler] draftActivate bookId={}, draft attachment count={}", bookId, count); + + if (count > 1) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, + "Only one attachment is allowed per book."); + } + } + + @On(event = BooksExtractDocumentDataContext.CDS_NAME) + public void onExtractDocumentData(BooksExtractDocumentDataContext context) { + // get attachment + String bookId = (String) CqnAnalyzer.create(cdsModel) + .analyze(context.getCqn()) + .rootKeys() + .get(Books_.ID); + + if (bookId == null) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Could not determine book ID."); + } + + BooksAttachments attachment = adminService.run( + Select.from(BooksAttachments_.class) + .columns(b -> b.ID(), b -> b.fileName(), b -> b.mimeType(), b -> b.content()) + .where(b -> b.up__ID().eq(bookId).and(b.IsActiveEntity().eq(true))) + ).first(BooksAttachments.class).orElse(null); + + if (attachment == null) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, + "No attachment found for this book. Please upload a document first."); + } + + if (attachment.getContent() == null) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, + "Attachment has no content. Please re-upload the document."); + } + + Service documentAiService = serviceCatalog.getService(Service.class, DocumentAiService_.CDS_NAME); + if (documentAiService == null) { + throw new ServiceException(ErrorStatuses.SERVER_ERROR, + "Document AI service is not available. Please ensure the cds-feature-sap-document-ai plugin is configured."); + } + + DocumentExtraction event = DocumentExtraction.create(); + event.setFileName(attachment.getFileName()); + event.setMimeType(attachment.getMimeType()); + event.setContent(attachment.getContent()); + try { + event.setOptions(objectMapper.writeValueAsString(java.util.Map.of( + "clientId", "default", + "documentType", "invoice", + "receivedDate", "2020-02-17", + "schemaId", "cf8cc8a9-1eee-42d9-9a3e-507a61baac23", + "templateId", "detect", + "candidateTemplateIds", java.util.List.of(), + "enrichment", java.util.Map.of()))); + } catch (JsonProcessingException e) { + throw new ServiceException(ErrorStatuses.SERVER_ERROR, "Failed to build extraction options", e); + } + + DocumentExtractionContext eventContext = DocumentExtractionContext.create(); + eventContext.setData(event); + + // emit event + documentAiService.emit(eventContext); + + logger.info("[DocumentExtractionHandler] Emitted DocumentExtraction event for bookId={}", bookId); + + context.setResult(true); + } +} diff --git a/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java b/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java new file mode 100644 index 0000000..9561f93 --- /dev/null +++ b/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java @@ -0,0 +1,28 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package customer.bookshop.handlers; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +@ServiceName(value = "*", type = ApplicationService.class) +public class DocumentExtractionResultHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(DocumentExtractionResultHandler.class); + + @On(event = DocumentExtractionResultContext.CDS_NAME) + public void onExtractionCompleted(DocumentExtractionResultContext context) { + DocumentExtractionResult data = context.getData(); + logger.info("[bookshop] Extraction completed & results are ready! jobId={} result ={}", data.getJobId(), data.getExtractionResult()); + context.setCompleted(); + } +} diff --git a/samples/document-ai-bookshop/srv/src/main/resources/application.yaml b/samples/document-ai-bookshop/srv/src/main/resources/application.yaml new file mode 100644 index 0000000..bde5da3 --- /dev/null +++ b/samples/document-ai-bookshop/srv/src/main/resources/application.yaml @@ -0,0 +1,39 @@ + +--- +spring: + config: + activate: + on-profile: default + sql: + init: + platform: h2 + h2: + console: + enabled: true +cds: + security: + mock: + users: + admin: + password: admin + roles: + - admin + user: + password: user + data-source: + auto-config: + enabled: false + outbox: + services: + DefaultOutboxUnordered: + maxAttempts: 10 + persistent: + scheduler: + enabled: true + document-ai: + polling: + interval-seconds: 5 + +logging: + level: + com.sap.cds.feature.documentai: DEBUG diff --git a/samples/document-ai-bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java b/samples/document-ai-bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java new file mode 100644 index 0000000..06c19b9 --- /dev/null +++ b/samples/document-ai-bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java @@ -0,0 +1,44 @@ +package customer.bookshop.handlers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.sap.cds.services.persistence.PersistenceService; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import cds.gen.catalogservice.Books; + +class CatalogServiceHandlerTest { + + private CatalogServiceHandler handler = new CatalogServiceHandler(Mockito.mock(PersistenceService.class)); + private Books book = Books.create(); + + @BeforeEach + 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()); + } + +}