From cc95d8a08887b7aee2dde8662da1f70d3b045dea Mon Sep 17 00:00:00 2001 From: Samyuktha Prabhu Date: Tue, 5 May 2026 10:34:36 +0200 Subject: [PATCH 01/70] Initial commit --- .gitignore | 24 +++++++ LICENSE | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + 3 files changed, 226 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..524f096 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..48f366f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# cds-feature-sap-document-ai \ No newline at end of file From f4157795e8fa37dd31cdaad01285480e61969d08 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 6 May 2026 14:49:02 +0200 Subject: [PATCH 02/70] feat: initialize CAP bookshop sample application --- .idea/vcs.xml | 6 + .idea/workspace.xml | 48 + bookshop/.cdsrc.json | 2 + bookshop/.gitignore | 31 + bookshop/app/_i18n/i18n.properties | 15 + bookshop/app/_i18n/i18n_de.properties | 15 + bookshop/app/admin-books/fiori-service.cds | 113 + bookshop/app/admin-books/webapp/Component.js | 8 + .../admin-books/webapp/i18n/i18n.properties | 3 + .../webapp/i18n/i18n_de.properties | 3 + bookshop/app/admin-books/webapp/manifest.json | 145 ++ .../app/appconfig/fioriSandboxConfig.json | 95 + bookshop/app/browse/fiori-service.cds | 51 + bookshop/app/browse/webapp/Component.js | 7 + .../app/browse/webapp/i18n/i18n.properties | 3 + .../app/browse/webapp/i18n/i18n_de.properties | 3 + bookshop/app/browse/webapp/manifest.json | 137 ++ bookshop/app/common.cds | 264 +++ bookshop/app/index.html | 32 + bookshop/app/services.cds | 6 + .../db/data/sap.capire.bookshop-Authors.csv | 5 + .../db/data/sap.capire.bookshop-Books.csv | 6 + .../data/sap.capire.bookshop-Books_texts.csv | 5 + .../db/data/sap.capire.bookshop-Genres.csv | 16 + bookshop/db/schema.cds | 37 + bookshop/package-lock.json | 1862 +++++++++++++++++ bookshop/package.json | 22 + bookshop/pom.xml | 146 ++ bookshop/srv/admin-service.cds | 6 + bookshop/srv/cat-service.cds | 34 + bookshop/srv/pom.xml | 144 ++ .../java/customer/bookshop/Application.java | 13 + .../handlers/CatalogServiceHandler.java | 65 + .../srv/src/main/resources/application.yaml | 22 + .../handlers/CatalogServiceHandlerTest.java | 42 + 35 files changed, 3412 insertions(+) create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 bookshop/.cdsrc.json create mode 100644 bookshop/.gitignore create mode 100644 bookshop/app/_i18n/i18n.properties create mode 100644 bookshop/app/_i18n/i18n_de.properties create mode 100644 bookshop/app/admin-books/fiori-service.cds create mode 100644 bookshop/app/admin-books/webapp/Component.js create mode 100644 bookshop/app/admin-books/webapp/i18n/i18n.properties create mode 100644 bookshop/app/admin-books/webapp/i18n/i18n_de.properties create mode 100644 bookshop/app/admin-books/webapp/manifest.json create mode 100644 bookshop/app/appconfig/fioriSandboxConfig.json create mode 100644 bookshop/app/browse/fiori-service.cds create mode 100644 bookshop/app/browse/webapp/Component.js create mode 100644 bookshop/app/browse/webapp/i18n/i18n.properties create mode 100644 bookshop/app/browse/webapp/i18n/i18n_de.properties create mode 100644 bookshop/app/browse/webapp/manifest.json create mode 100644 bookshop/app/common.cds create mode 100644 bookshop/app/index.html create mode 100644 bookshop/app/services.cds create mode 100644 bookshop/db/data/sap.capire.bookshop-Authors.csv create mode 100644 bookshop/db/data/sap.capire.bookshop-Books.csv create mode 100644 bookshop/db/data/sap.capire.bookshop-Books_texts.csv create mode 100644 bookshop/db/data/sap.capire.bookshop-Genres.csv create mode 100644 bookshop/db/schema.cds create mode 100644 bookshop/package-lock.json create mode 100644 bookshop/package.json create mode 100644 bookshop/pom.xml create mode 100644 bookshop/srv/admin-service.cds create mode 100644 bookshop/srv/cat-service.cds create mode 100644 bookshop/srv/pom.xml create mode 100644 bookshop/srv/src/main/java/customer/bookshop/Application.java create mode 100644 bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java create mode 100644 bookshop/srv/src/main/resources/application.yaml create mode 100644 bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..0d90dfa --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,48 @@ + + + + + + + + { + "associatedIndex": 6 +} + + + + + + + + 1778067993932 + + + + + + \ No newline at end of file diff --git a/bookshop/.cdsrc.json b/bookshop/.cdsrc.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/bookshop/.cdsrc.json @@ -0,0 +1,2 @@ +{ +} diff --git a/bookshop/.gitignore b/bookshop/.gitignore new file mode 100644 index 0000000..c161f22 --- /dev/null +++ b/bookshop/.gitignore @@ -0,0 +1,31 @@ +**/gen/ +**/edmx/ +*.db +*.sqlite +*.sqlite-wal +*.sqlite-shm +schema*.sql +default-env.json + +**/bin/ +**/target/ +.flattened-pom.xml +.classpath +.project +.settings + +**/node/ +**/node_modules/ + +**/.mta/ +*.mtar + +*.log* +gc_history* +hs_err* +*.tgz +*.iml + +.vscode +.idea +.reloadtrigger diff --git a/bookshop/app/_i18n/i18n.properties b/bookshop/app/_i18n/i18n.properties new file mode 100644 index 0000000..7326bbb --- /dev/null +++ b/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/bookshop/app/_i18n/i18n_de.properties b/bookshop/app/_i18n/i18n_de.properties new file mode 100644 index 0000000..cb712c1 --- /dev/null +++ b/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/bookshop/app/admin-books/fiori-service.cds b/bookshop/app/admin-books/fiori-service.cds new file mode 100644 index 0000000..36fa090 --- /dev/null +++ b/bookshop/app/admin-books/fiori-service.cds @@ -0,0 +1,113 @@ +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; +} diff --git a/bookshop/app/admin-books/webapp/Component.js b/bookshop/app/admin-books/webapp/Component.js new file mode 100644 index 0000000..e98677e --- /dev/null +++ b/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/bookshop/app/admin-books/webapp/i18n/i18n.properties b/bookshop/app/admin-books/webapp/i18n/i18n.properties new file mode 100644 index 0000000..9a23ee4 --- /dev/null +++ b/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/bookshop/app/admin-books/webapp/i18n/i18n_de.properties b/bookshop/app/admin-books/webapp/i18n/i18n_de.properties new file mode 100644 index 0000000..01d56a2 --- /dev/null +++ b/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/bookshop/app/admin-books/webapp/manifest.json b/bookshop/app/admin-books/webapp/manifest.json new file mode 100644 index 0000000..4bcc54c --- /dev/null +++ b/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/bookshop/app/appconfig/fioriSandboxConfig.json b/bookshop/app/appconfig/fioriSandboxConfig.json new file mode 100644 index 0000000..ff2ac49 --- /dev/null +++ b/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/bookshop/app/browse/fiori-service.cds b/bookshop/app/browse/fiori-service.cds new file mode 100644 index 0000000..b49a94f --- /dev/null +++ b/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/bookshop/app/browse/webapp/Component.js b/bookshop/app/browse/webapp/Component.js new file mode 100644 index 0000000..4020679 --- /dev/null +++ b/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/bookshop/app/browse/webapp/i18n/i18n.properties b/bookshop/app/browse/webapp/i18n/i18n.properties new file mode 100644 index 0000000..21436e8 --- /dev/null +++ b/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/bookshop/app/browse/webapp/i18n/i18n_de.properties b/bookshop/app/browse/webapp/i18n/i18n_de.properties new file mode 100644 index 0000000..ea86c3f --- /dev/null +++ b/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/bookshop/app/browse/webapp/manifest.json b/bookshop/app/browse/webapp/manifest.json new file mode 100644 index 0000000..cd4b1c3 --- /dev/null +++ b/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/bookshop/app/common.cds b/bookshop/app/common.cds new file mode 100644 index 0000000..69627be --- /dev/null +++ b/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/bookshop/app/index.html b/bookshop/app/index.html new file mode 100644 index 0000000..70f6315 --- /dev/null +++ b/bookshop/app/index.html @@ -0,0 +1,32 @@ + + + + + + + + Bookshop + + + + + + + + + + diff --git a/bookshop/app/services.cds b/bookshop/app/services.cds new file mode 100644 index 0000000..87e7b31 --- /dev/null +++ b/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/bookshop/db/data/sap.capire.bookshop-Authors.csv b/bookshop/db/data/sap.capire.bookshop-Authors.csv new file mode 100644 index 0000000..5272ee1 --- /dev/null +++ b/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/bookshop/db/data/sap.capire.bookshop-Books.csv b/bookshop/db/data/sap.capire.bookshop-Books.csv new file mode 100644 index 0000000..46d63fa --- /dev/null +++ b/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/bookshop/db/data/sap.capire.bookshop-Books_texts.csv b/bookshop/db/data/sap.capire.bookshop-Books_texts.csv new file mode 100644 index 0000000..3a3465b --- /dev/null +++ b/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/bookshop/db/data/sap.capire.bookshop-Genres.csv b/bookshop/db/data/sap.capire.bookshop-Genres.csv new file mode 100644 index 0000000..1ea3793 --- /dev/null +++ b/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/bookshop/db/schema.cds b/bookshop/db/schema.cds new file mode 100644 index 0000000..1aedfba --- /dev/null +++ b/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/bookshop/package-lock.json b/bookshop/package-lock.json new file mode 100644 index 0000000..336e3c9 --- /dev/null +++ b/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/bookshop/package.json b/bookshop/package.json new file mode 100644 index 0000000..09871b0 --- /dev/null +++ b/bookshop/package.json @@ -0,0 +1,22 @@ +{ + "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": { + "db-ext": { + "[development]": { + "model": "db/sqlite" + }, + "[production]": { + "model": "db/hana" + } + } + } + } +} diff --git a/bookshop/pom.xml b/bookshop/pom.xml new file mode 100644 index 0000000..5eebbae --- /dev/null +++ b/bookshop/pom.xml @@ -0,0 +1,146 @@ + + + 4.0.0 + + customer + bookshop-parent + ${revision} + pom + + bookshop parent + + + + 1.0.0-SNAPSHOT + + + 17 + 4.6.0 + 3.5.8 + + https://nodejs.org/dist/ + UTF-8 + + + + srv + + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + + + + com.sap.cds + cds-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/bookshop/srv/admin-service.cds b/bookshop/srv/admin-service.cds new file mode 100644 index 0000000..9ae8bbc --- /dev/null +++ b/bookshop/srv/admin-service.cds @@ -0,0 +1,6 @@ +using {sap.capire.bookshop as my} from '../db/schema'; + +service AdminService @(requires: 'admin') { + entity Books as projection on my.Books; + entity Authors as projection on my.Authors; +} diff --git a/bookshop/srv/cat-service.cds b/bookshop/srv/cat-service.cds new file mode 100644 index 0000000..1d2cbba --- /dev/null +++ b/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/bookshop/srv/pom.xml b/bookshop/srv/pom.xml new file mode 100644 index 0000000..f2d7fba --- /dev/null +++ b/bookshop/srv/pom.xml @@ -0,0 +1,144 @@ + + 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 + + + + + ${project.artifactId} + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + false + + + + repackage + + repackage + + + exec + + + + + + + + com.sap.cds + cds-maven-plugin + + + cds.clean + + clean + + + + + cds.install-node + + install-node + + + + + cds.npm-ci + + npm + + + ci + + + + + cds.resolve + + resolve + + + + + cds.build + + cds + + + + build --for java + deploy --to h2 --with-mocks --dry --out "${project.basedir}/src/main/resources/schema-h2.sql" + + + + + + cds.generate + + generate + + + cds.gen + true + true + true + true + + + + + + + + \ No newline at end of file diff --git a/bookshop/srv/src/main/java/customer/bookshop/Application.java b/bookshop/srv/src/main/java/customer/bookshop/Application.java new file mode 100644 index 0000000..f395d21 --- /dev/null +++ b/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/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java b/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java new file mode 100644 index 0000000..934a4be --- /dev/null +++ b/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java @@ -0,0 +1,65 @@ +package customer.bookshop.handlers; + +import static cds.gen.catalogservice.CatalogService_.BOOKS; + +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.persistence.PersistenceService; + +import cds.gen.catalogservice.Books; +import cds.gen.catalogservice.CatalogService_; +import cds.gen.catalogservice.OrderedBook; +import cds.gen.catalogservice.OrderedBookContext; +import cds.gen.catalogservice.SubmitOrderContext; +import cds.gen.catalogservice.SubmitOrderContext.ReturnType; + +@Component +@ServiceName(CatalogService_.CDS_NAME) +public class CatalogServiceHandler implements EventHandler { + + @Autowired + private PersistenceService db; + + @On + public 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/bookshop/srv/src/main/resources/application.yaml b/bookshop/srv/src/main/resources/application.yaml new file mode 100644 index 0000000..1ee2ae9 --- /dev/null +++ b/bookshop/srv/src/main/resources/application.yaml @@ -0,0 +1,22 @@ + +--- +spring: + config: + activate: + on-profile: default + sql: + init: + platform: h2 +cds: + security: + mock: + users: + admin: + password: admin + roles: + - admin + user: + password: user + data-source: + auto-config: + enabled: false diff --git a/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java b/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java new file mode 100644 index 0000000..6c510cd --- /dev/null +++ b/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java @@ -0,0 +1,42 @@ +package customer.bookshop.handlers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import cds.gen.catalogservice.Books; + +class CatalogServiceHandlerTest { + + private CatalogServiceHandler handler = new CatalogServiceHandler(); + private Books book = Books.create(); + + @BeforeEach + public void prepareBook() { + book.setTitle("title"); + } + + @Test + void testDiscount() { + book.setStock(500); + handler.discountBooks(Stream.of(book)); + assertEquals("title (discounted)", book.getTitle()); + } + + @Test + void testNoDiscount() { + book.setStock(100); + handler.discountBooks(Stream.of(book)); + assertEquals("title", book.getTitle()); + } + + @Test + void testNoStockAvailable() { + handler.discountBooks(Stream.of(book)); + assertEquals("title", book.getTitle()); + } + +} From 8282df146c9eacd4206c12bec29dd6e6c4ab772d Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 6 May 2026 16:23:21 +0200 Subject: [PATCH 03/70] add plugin scaffold --- .gitignore | 6 +++ .idea/vcs.xml | 6 --- .idea/workspace.xml | 48 ------------------- bookshop/pom.xml | 6 +++ sap-document-ai/pom.xml | 18 +++++++ .../src/main/java/com/sap/cds/App.java | 13 +++++ .../src/test/java/com/sap/cds/AppTest.java | 38 +++++++++++++++ 7 files changed, 81 insertions(+), 54 deletions(-) delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/workspace.xml create mode 100644 sap-document-ai/pom.xml create mode 100644 sap-document-ai/src/main/java/com/sap/cds/App.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/AppTest.java diff --git a/.gitignore b/.gitignore index 524f096..5f68014 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* + +# Maven build output +target/ + +# IDE +.idea/ diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 0d90dfa..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - { - "associatedIndex": 6 -} - - - - - - - - 1778067993932 - - - - - - \ No newline at end of file diff --git a/bookshop/pom.xml b/bookshop/pom.xml index 5eebbae..6291085 100644 --- a/bookshop/pom.xml +++ b/bookshop/pom.xml @@ -39,6 +39,12 @@ import + + com.sap.cds + sap-document-ai + 1.0-SNAPSHOT + + org.springframework.boot diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml new file mode 100644 index 0000000..9660eaa --- /dev/null +++ b/sap-document-ai/pom.xml @@ -0,0 +1,18 @@ + + 4.0.0 + com.sap.cds + sap-document-ai + jar + 1.0-SNAPSHOT + sap-document-ai + http://maven.apache.org + + + junit + junit + 3.8.1 + test + + + diff --git a/sap-document-ai/src/main/java/com/sap/cds/App.java b/sap-document-ai/src/main/java/com/sap/cds/App.java new file mode 100644 index 0000000..cd53cc9 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/App.java @@ -0,0 +1,13 @@ +package com.sap.cds; + +/** + * Hello world! + * + */ +public class App +{ + public static void main( String[] args ) + { + System.out.println( "Hello World!" ); + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/AppTest.java b/sap-document-ai/src/test/java/com/sap/cds/AppTest.java new file mode 100644 index 0000000..844e4f3 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/AppTest.java @@ -0,0 +1,38 @@ +package com.sap.cds; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + * Unit test for simple App. + */ +public class AppTest + extends TestCase +{ + /** + * Create the test case + * + * @param testName name of the test case + */ + public AppTest( String testName ) + { + super( testName ); + } + + /** + * @return the suite of tests being tested + */ + public static Test suite() + { + return new TestSuite( AppTest.class ); + } + + /** + * Rigourous Test :-) + */ + public void testApp() + { + assertTrue( true ); + } +} From 50a118e53bb7dd16109b799f14aabe95b306ac92 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 7 May 2026 10:31:58 +0200 Subject: [PATCH 04/70] update plugin scaffold --- bookshop/srv/pom.xml | 6 +++ sap-document-ai/pom.xml | 49 ++++++++++++++++++- .../src/main/java/com/sap/cds/App.java | 13 ----- .../java/com/sap/cds/DocumentAiHandler.java | 21 ++++++++ ...s.services.runtime.CdsRuntimeConfiguration | 1 + .../src/test/java/com/sap/cds/AppTest.java | 38 -------------- .../com/sap/cds/DocumentAiHandlerTest.java | 21 ++++++++ 7 files changed, 97 insertions(+), 52 deletions(-) delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/App.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java create mode 100644 sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration delete mode 100644 sap-document-ai/src/test/java/com/sap/cds/AppTest.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java diff --git a/bookshop/srv/pom.xml b/bookshop/srv/pom.xml index f2d7fba..b1be1e5 100644 --- a/bookshop/srv/pom.xml +++ b/bookshop/srv/pom.xml @@ -48,6 +48,12 @@ org.springframework.boot spring-boot-starter-security + + + com.sap.cds + sap-document-ai + 1.0-SNAPSHOT + diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 9660eaa..a2fdd36 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -6,8 +6,43 @@ jar 1.0-SNAPSHOT sap-document-ai - http://maven.apache.org + + + 17 + 4.6.0 + UTF-8 + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + + + com.sap.cds + cds-services-api + provided + + + com.sap.cds + cds4j-core + ${cds.services.version} + provided + + + org.springframework + spring-context + 6.2.6 + provided + junit junit @@ -15,4 +50,16 @@ test + + + + + maven-compiler-plugin + 3.14.1 + + ${jdk.version} + + + + diff --git a/sap-document-ai/src/main/java/com/sap/cds/App.java b/sap-document-ai/src/main/java/com/sap/cds/App.java deleted file mode 100644 index cd53cc9..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/App.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.sap.cds; - -/** - * Hello world! - * - */ -public class App -{ - public static void main( String[] args ) - { - System.out.println( "Hello World!" ); - } -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java b/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java new file mode 100644 index 0000000..1f84942 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java @@ -0,0 +1,21 @@ +package com.sap.cds; + +import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.Before; +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 = com.sap.cds.services.Service.class) +public class DocumentAiHandler implements EventHandler { + + private static final Logger log = LoggerFactory.getLogger(DocumentAiHandler.class); + + @Before(event = CqnService.EVENT_READ) + public void beforeRead() { + log.info("[sap-document-ai] Before READ event triggered"); + } +} diff --git a/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..a070229 --- /dev/null +++ b/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.DocumentAiHandler diff --git a/sap-document-ai/src/test/java/com/sap/cds/AppTest.java b/sap-document-ai/src/test/java/com/sap/cds/AppTest.java deleted file mode 100644 index 844e4f3..0000000 --- a/sap-document-ai/src/test/java/com/sap/cds/AppTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.sap.cds; - -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -/** - * Unit test for simple App. - */ -public class AppTest - extends TestCase -{ - /** - * Create the test case - * - * @param testName name of the test case - */ - public AppTest( String testName ) - { - super( testName ); - } - - /** - * @return the suite of tests being tested - */ - public static Test suite() - { - return new TestSuite( AppTest.class ); - } - - /** - * Rigourous Test :-) - */ - public void testApp() - { - assertTrue( true ); - } -} diff --git a/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java new file mode 100644 index 0000000..f06c0c3 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java @@ -0,0 +1,21 @@ +package com.sap.cds; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +public class DocumentAiHandlerTest extends TestCase { + + public DocumentAiHandlerTest(String testName) { + super(testName); + } + + public static Test suite() { + return new TestSuite(DocumentAiHandlerTest.class); + } + + public void testHandlerCanBeInstantiated() { + DocumentAiHandler handler = new DocumentAiHandler(); + assertNotNull(handler); + } +} From bb0a871d5e5565208e4e5030373aa24b18c7c60a Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 13 May 2026 11:55:15 +0200 Subject: [PATCH 05/70] enable attachments remove extra files rename handler tiny fixes --- bookshop/pom.xml | 6 +++ bookshop/srv/attachments.cds | 26 +++++++++++++ bookshop/srv/pom.xml | 5 +++ sap-document-ai/pom.xml | 10 +++++ .../java/com/sap/cds/DocumentAiHandler.java | 21 ----------- .../sap/cds/configuration/Registration.java | 12 ++++++ .../handlers/AttachmentExtractionHandler.java | 37 +++++++++++++++++++ ...s.services.runtime.CdsRuntimeConfiguration | 1 - ...s.services.runtime.CdsRuntimeConfiguration | 1 + .../cds/AttachmentExtractionHandlerTest.java | 22 +++++++++++ .../com/sap/cds/DocumentAiHandlerTest.java | 21 ----------- 11 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 bookshop/srv/attachments.cds delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java delete mode 100644 sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration create mode 100644 sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration create mode 100644 sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java delete mode 100644 sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java diff --git a/bookshop/pom.xml b/bookshop/pom.xml index 6291085..3a53afc 100644 --- a/bookshop/pom.xml +++ b/bookshop/pom.xml @@ -45,6 +45,12 @@ 1.0-SNAPSHOT + + com.sap.cds + cds-feature-attachments + 1.5.0 + + org.springframework.boot diff --git a/bookshop/srv/attachments.cds b/bookshop/srv/attachments.cds new file mode 100644 index 0000000..32335bf --- /dev/null +++ b/bookshop/srv/attachments.cds @@ -0,0 +1,26 @@ +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' +}]); diff --git a/bookshop/srv/pom.xml b/bookshop/srv/pom.xml index b1be1e5..1a057ac 100644 --- a/bookshop/srv/pom.xml +++ b/bookshop/srv/pom.xml @@ -54,6 +54,11 @@ sap-document-ai 1.0-SNAPSHOT + + + com.sap.cds + cds-feature-attachments + diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index a2fdd36..1e98844 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -22,6 +22,11 @@ pom import + + com.sap.cds + cds-feature-attachments + 1.5.0 + @@ -43,6 +48,11 @@ 6.2.6 provided + + com.sap.cds + cds-feature-attachments + provided + junit junit diff --git a/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java b/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java deleted file mode 100644 index 1f84942..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sap.cds; - -import com.sap.cds.services.cds.CqnService; -import com.sap.cds.services.handler.EventHandler; -import com.sap.cds.services.handler.annotations.Before; -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 = com.sap.cds.services.Service.class) -public class DocumentAiHandler implements EventHandler { - - private static final Logger log = LoggerFactory.getLogger(DocumentAiHandler.class); - - @Before(event = CqnService.EVENT_READ) - public void beforeRead() { - log.info("[sap-document-ai] Before READ event triggered"); - } -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java new file mode 100644 index 0000000..13c5721 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java @@ -0,0 +1,12 @@ +package com.sap.cds.configuration; + +import com.sap.cds.handlers.AttachmentExtractionHandler; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; + +public class Registration implements CdsRuntimeConfiguration { + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + configurer.eventHandler(new AttachmentExtractionHandler()); + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java new file mode 100644 index 0000000..8c5e2f9 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java @@ -0,0 +1,37 @@ +package com.sap.cds.handlers; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.services.EventContext; +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; + +/* +* Currently a placeholder handler, to integrate with attachments and test if it is triggered :) +* */ +@ServiceName(value = "*", type = AttachmentService.class) +public class AttachmentExtractionHandler implements EventHandler { + + private static final Logger log = LoggerFactory.getLogger(AttachmentExtractionHandler.class); + + @Before(event = "*") + public void debug(EventContext context) { + log.info("[sap-document-ai] Event fired: {}", context.getEvent()); + } + + @On(event = AttachmentService.EVENT_CREATE_ATTACHMENT) + public void onCreateAttachment(AttachmentCreateEventContext context) { + log.info("[sap-document-ai] Attachment created: {}", context.getAttachmentIds().get(Attachments.ID)); + String contentId = (String) context.getAttachmentIds().get(Attachments.ID); + context.setContentId(contentId); + context.setIsInternalStored(true); + // TODO: call SAP Document AI with context.getData() to extract document content + context.setCompleted(); + } + +} diff --git a/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration deleted file mode 100644 index a070229..0000000 --- a/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration +++ /dev/null @@ -1 +0,0 @@ -com.sap.cds.DocumentAiHandler diff --git a/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..62d332d --- /dev/null +++ b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.configuration.Registration \ No newline at end of file diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java new file mode 100644 index 0000000..8193caf --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java @@ -0,0 +1,22 @@ +package com.sap.cds; + +import com.sap.cds.handlers.AttachmentExtractionHandler; +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +public class AttachmentExtractionHandlerTest extends TestCase { + + public AttachmentExtractionHandlerTest(String testName) { + super(testName); + } + + public static Test suite() { + return new TestSuite(AttachmentExtractionHandlerTest.class); + } + + public void testHandlerCanBeInstantiated() { + AttachmentExtractionHandler handler = new AttachmentExtractionHandler(); + assertNotNull(handler); + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java deleted file mode 100644 index f06c0c3..0000000 --- a/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sap.cds; - -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -public class DocumentAiHandlerTest extends TestCase { - - public DocumentAiHandlerTest(String testName) { - super(testName); - } - - public static Test suite() { - return new TestSuite(DocumentAiHandlerTest.class); - } - - public void testHandlerCanBeInstantiated() { - DocumentAiHandler handler = new DocumentAiHandler(); - assertNotNull(handler); - } -} From f4173e0dd20a6001d32c6c76527cbe863153c734 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 13 May 2026 11:55:15 +0200 Subject: [PATCH 06/70] enable attachments remove extra files rename handler tiny fixes --- bookshop/pom.xml | 6 +++ bookshop/srv/attachments.cds | 26 +++++++++++++ bookshop/srv/pom.xml | 5 +++ sap-document-ai/pom.xml | 10 +++++ .../java/com/sap/cds/DocumentAiHandler.java | 21 ----------- .../sap/cds/configuration/Registration.java | 12 ++++++ .../handlers/AttachmentExtractionHandler.java | 37 +++++++++++++++++++ ...s.services.runtime.CdsRuntimeConfiguration | 1 - ...s.services.runtime.CdsRuntimeConfiguration | 1 + .../cds/AttachmentExtractionHandlerTest.java | 22 +++++++++++ .../com/sap/cds/DocumentAiHandlerTest.java | 21 ----------- 11 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 bookshop/srv/attachments.cds delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java delete mode 100644 sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration create mode 100644 sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration create mode 100644 sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java delete mode 100644 sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java diff --git a/bookshop/pom.xml b/bookshop/pom.xml index 6291085..3a53afc 100644 --- a/bookshop/pom.xml +++ b/bookshop/pom.xml @@ -45,6 +45,12 @@ 1.0-SNAPSHOT + + com.sap.cds + cds-feature-attachments + 1.5.0 + + org.springframework.boot diff --git a/bookshop/srv/attachments.cds b/bookshop/srv/attachments.cds new file mode 100644 index 0000000..32335bf --- /dev/null +++ b/bookshop/srv/attachments.cds @@ -0,0 +1,26 @@ +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' +}]); diff --git a/bookshop/srv/pom.xml b/bookshop/srv/pom.xml index b1be1e5..1a057ac 100644 --- a/bookshop/srv/pom.xml +++ b/bookshop/srv/pom.xml @@ -54,6 +54,11 @@ sap-document-ai 1.0-SNAPSHOT + + + com.sap.cds + cds-feature-attachments + diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index a2fdd36..1e98844 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -22,6 +22,11 @@ pom import + + com.sap.cds + cds-feature-attachments + 1.5.0 + @@ -43,6 +48,11 @@ 6.2.6 provided + + com.sap.cds + cds-feature-attachments + provided + junit junit diff --git a/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java b/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java deleted file mode 100644 index 1f84942..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/DocumentAiHandler.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sap.cds; - -import com.sap.cds.services.cds.CqnService; -import com.sap.cds.services.handler.EventHandler; -import com.sap.cds.services.handler.annotations.Before; -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 = com.sap.cds.services.Service.class) -public class DocumentAiHandler implements EventHandler { - - private static final Logger log = LoggerFactory.getLogger(DocumentAiHandler.class); - - @Before(event = CqnService.EVENT_READ) - public void beforeRead() { - log.info("[sap-document-ai] Before READ event triggered"); - } -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java new file mode 100644 index 0000000..13c5721 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java @@ -0,0 +1,12 @@ +package com.sap.cds.configuration; + +import com.sap.cds.handlers.AttachmentExtractionHandler; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; + +public class Registration implements CdsRuntimeConfiguration { + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + configurer.eventHandler(new AttachmentExtractionHandler()); + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java new file mode 100644 index 0000000..8c5e2f9 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java @@ -0,0 +1,37 @@ +package com.sap.cds.handlers; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.services.EventContext; +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; + +/* +* Currently a placeholder handler, to integrate with attachments and test if it is triggered :) +* */ +@ServiceName(value = "*", type = AttachmentService.class) +public class AttachmentExtractionHandler implements EventHandler { + + private static final Logger log = LoggerFactory.getLogger(AttachmentExtractionHandler.class); + + @Before(event = "*") + public void debug(EventContext context) { + log.info("[sap-document-ai] Event fired: {}", context.getEvent()); + } + + @On(event = AttachmentService.EVENT_CREATE_ATTACHMENT) + public void onCreateAttachment(AttachmentCreateEventContext context) { + log.info("[sap-document-ai] Attachment created: {}", context.getAttachmentIds().get(Attachments.ID)); + String contentId = (String) context.getAttachmentIds().get(Attachments.ID); + context.setContentId(contentId); + context.setIsInternalStored(true); + // TODO: call SAP Document AI with context.getData() to extract document content + context.setCompleted(); + } + +} diff --git a/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration deleted file mode 100644 index a070229..0000000 --- a/sap-document-ai/src/main/resources/META-INF.services/com.sap.cds.services.runtime.CdsRuntimeConfiguration +++ /dev/null @@ -1 +0,0 @@ -com.sap.cds.DocumentAiHandler diff --git a/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..62d332d --- /dev/null +++ b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.configuration.Registration \ No newline at end of file diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java new file mode 100644 index 0000000..8193caf --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java @@ -0,0 +1,22 @@ +package com.sap.cds; + +import com.sap.cds.handlers.AttachmentExtractionHandler; +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +public class AttachmentExtractionHandlerTest extends TestCase { + + public AttachmentExtractionHandlerTest(String testName) { + super(testName); + } + + public static Test suite() { + return new TestSuite(AttachmentExtractionHandlerTest.class); + } + + public void testHandlerCanBeInstantiated() { + AttachmentExtractionHandler handler = new AttachmentExtractionHandler(); + assertNotNull(handler); + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java deleted file mode 100644 index f06c0c3..0000000 --- a/sap-document-ai/src/test/java/com/sap/cds/DocumentAiHandlerTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sap.cds; - -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -public class DocumentAiHandlerTest extends TestCase { - - public DocumentAiHandlerTest(String testName) { - super(testName); - } - - public static Test suite() { - return new TestSuite(DocumentAiHandlerTest.class); - } - - public void testHandlerCanBeInstantiated() { - DocumentAiHandler handler = new DocumentAiHandler(); - assertNotNull(handler); - } -} From 5e82b232f6387c6695126bd7134c601aefa00e71 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 20 May 2026 16:09:30 +0200 Subject: [PATCH 07/70] setup orchestrator --- bookshop/.gitignore | 3 ++ sap-document-ai/pom.xml | 12 ++++-- .../sap/cds/configuration/Registration.java | 6 ++- .../cds/handlers/AttachmentEventHandler.java | 37 +++++++++++++++++++ .../handlers/AttachmentExtractionHandler.java | 37 ------------------- .../orchestrator/ExtractionOrchestrator.java | 14 +++++++ .../sap/cds/AttachmentEventHandlerTest.java | 32 ++++++++++++++++ .../cds/AttachmentExtractionHandlerTest.java | 22 ----------- 8 files changed, 99 insertions(+), 64 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java delete mode 100644 sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java diff --git a/bookshop/.gitignore b/bookshop/.gitignore index c161f22..2ecc68d 100644 --- a/bookshop/.gitignore +++ b/bookshop/.gitignore @@ -29,3 +29,6 @@ hs_err* .vscode .idea .reloadtrigger + +# added by cds +.cdsrc-private.json diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 1e98844..56cde7c 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -54,9 +54,15 @@ provided - junit - junit - 3.8.1 + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + org.mockito + mockito-junit-jupiter + 5.12.0 test diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java index 13c5721..afb148f 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java @@ -1,12 +1,14 @@ package com.sap.cds.configuration; -import com.sap.cds.handlers.AttachmentExtractionHandler; +import com.sap.cds.handlers.AttachmentEventHandler; +import com.sap.cds.orchestrator.ExtractionOrchestrator; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; public class Registration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - configurer.eventHandler(new AttachmentExtractionHandler()); + ExtractionOrchestrator extractionOrchestrator = new ExtractionOrchestrator(); + configurer.eventHandler(new AttachmentEventHandler(extractionOrchestrator)); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java new file mode 100644 index 0000000..afbaf07 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -0,0 +1,37 @@ +package com.sap.cds.handlers; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.ServiceName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/* +* Currently a placeholder handler, to integrate with attachments and test if it is triggered :) +* */ +@ServiceName(value = "*", type = AttachmentService.class) +public class AttachmentEventHandler implements EventHandler { + + private static final Logger log = LoggerFactory.getLogger(AttachmentEventHandler.class); + + private final ExtractionOrchestrator extractionOrchestrator; + + public AttachmentEventHandler(ExtractionOrchestrator extractionOrchestrator){ + this.extractionOrchestrator = extractionOrchestrator; + } + + @After(event = AttachmentService.EVENT_CREATE_ATTACHMENT) + public void afterCreateAttachment(AttachmentCreateEventContext context) { + log.info("[sap-document-ai] After Attachment created: {}", context.getAttachmentIds().get(Attachments.ID)); + String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); + log.info("[sap-document-ai] Attachment persisted successfully. Triggering extraction workflow for attachmentId={}", attachmentId); + log.info("[sap-document-ai] attachmentIds={}", context.getAttachmentIds()); + log.info("[sap-document-ai] data={}", context.getData()); + extractionOrchestrator.startExtraction(attachmentId); + } + +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java deleted file mode 100644 index 8c5e2f9..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sap.cds.handlers; - -import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; -import com.sap.cds.feature.attachments.service.AttachmentService; -import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; -import com.sap.cds.services.EventContext; -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; - -/* -* Currently a placeholder handler, to integrate with attachments and test if it is triggered :) -* */ -@ServiceName(value = "*", type = AttachmentService.class) -public class AttachmentExtractionHandler implements EventHandler { - - private static final Logger log = LoggerFactory.getLogger(AttachmentExtractionHandler.class); - - @Before(event = "*") - public void debug(EventContext context) { - log.info("[sap-document-ai] Event fired: {}", context.getEvent()); - } - - @On(event = AttachmentService.EVENT_CREATE_ATTACHMENT) - public void onCreateAttachment(AttachmentCreateEventContext context) { - log.info("[sap-document-ai] Attachment created: {}", context.getAttachmentIds().get(Attachments.ID)); - String contentId = (String) context.getAttachmentIds().get(Attachments.ID); - context.setContentId(contentId); - context.setIsInternalStored(true); - // TODO: call SAP Document AI with context.getData() to extract document content - context.setCompleted(); - } - -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java new file mode 100644 index 0000000..f7ac389 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java @@ -0,0 +1,14 @@ +package com.sap.cds.orchestrator; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ExtractionOrchestrator { + + private static final Logger logger = LoggerFactory.getLogger(ExtractionOrchestrator.class); + + public void startExtraction(String attachmentId) { + logger.info("[sap-document-ai] Orchestrator triggered w/ attachment id {}", attachmentId); + } + +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java new file mode 100644 index 0000000..f13808c --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -0,0 +1,32 @@ +package com.sap.cds; + +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.handlers.AttachmentEventHandler; +import com.sap.cds.orchestrator.ExtractionOrchestrator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AttachmentEventHandlerTest { + + @Mock + ExtractionOrchestrator extractionOrchestrator; + + @Test + void afterCreateAttachment_triggersOrchestration() { + AttachmentEventHandler handler = new AttachmentEventHandler(extractionOrchestrator); + assertNotNull(handler); + AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); + when(context.getAttachmentIds()).thenReturn(Map.of("ID", "test-attachment-id")); + handler.afterCreateAttachment(context); + verify(extractionOrchestrator).startExtraction("test-attachment-id"); + } + +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java deleted file mode 100644 index 8193caf..0000000 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.sap.cds; - -import com.sap.cds.handlers.AttachmentExtractionHandler; -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -public class AttachmentExtractionHandlerTest extends TestCase { - - public AttachmentExtractionHandlerTest(String testName) { - super(testName); - } - - public static Test suite() { - return new TestSuite(AttachmentExtractionHandlerTest.class); - } - - public void testHandlerCanBeInstantiated() { - AttachmentExtractionHandler handler = new AttachmentExtractionHandler(); - assertNotNull(handler); - } -} From f25bd3b62dae735a4fbe6287501866da27647622 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 20 May 2026 17:45:07 +0200 Subject: [PATCH 08/70] rename handler and update to junit 5 --- sap-document-ai/pom.xml | 6 ++--- .../sap/cds/configuration/Registration.java | 4 ++-- ...ndler.java => AttachmentEventHandler.java} | 15 ++----------- .../sap/cds/AttachmentEventHandlerTest.java | 15 +++++++++++++ .../cds/AttachmentExtractionHandlerTest.java | 22 ------------------- 5 files changed, 22 insertions(+), 40 deletions(-) rename sap-document-ai/src/main/java/com/sap/cds/handlers/{AttachmentExtractionHandler.java => AttachmentEventHandler.java} (66%) create mode 100644 sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java delete mode 100644 sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 1e98844..1e09a64 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -54,9 +54,9 @@ provided - junit - junit - 3.8.1 + org.junit.jupiter + junit-jupiter + 5.11.0 test diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java index 13c5721..ee2d391 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java @@ -1,12 +1,12 @@ package com.sap.cds.configuration; -import com.sap.cds.handlers.AttachmentExtractionHandler; +import com.sap.cds.handlers.AttachmentEventHandler; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; public class Registration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - configurer.eventHandler(new AttachmentExtractionHandler()); + configurer.eventHandler(new AttachmentEventHandler()); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java similarity index 66% rename from sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java rename to sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 8c5e2f9..5829362 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentExtractionHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -15,23 +15,12 @@ * Currently a placeholder handler, to integrate with attachments and test if it is triggered :) * */ @ServiceName(value = "*", type = AttachmentService.class) -public class AttachmentExtractionHandler implements EventHandler { +public class AttachmentEventHandler implements EventHandler { - private static final Logger log = LoggerFactory.getLogger(AttachmentExtractionHandler.class); - - @Before(event = "*") - public void debug(EventContext context) { - log.info("[sap-document-ai] Event fired: {}", context.getEvent()); - } + private static final Logger log = LoggerFactory.getLogger(AttachmentEventHandler.class); @On(event = AttachmentService.EVENT_CREATE_ATTACHMENT) public void onCreateAttachment(AttachmentCreateEventContext context) { log.info("[sap-document-ai] Attachment created: {}", context.getAttachmentIds().get(Attachments.ID)); - String contentId = (String) context.getAttachmentIds().get(Attachments.ID); - context.setContentId(contentId); - context.setIsInternalStored(true); - // TODO: call SAP Document AI with context.getData() to extract document content - context.setCompleted(); } - } diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java new file mode 100644 index 0000000..3824948 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -0,0 +1,15 @@ +package com.sap.cds; + +import com.sap.cds.handlers.AttachmentEventHandler; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class AttachmentEventHandlerTest { + + @Test + public void testHandlerCanBeInstantiated() { + AttachmentEventHandler handler = new AttachmentEventHandler(); + assertNotNull(handler); + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java deleted file mode 100644 index 8193caf..0000000 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentExtractionHandlerTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.sap.cds; - -import com.sap.cds.handlers.AttachmentExtractionHandler; -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -public class AttachmentExtractionHandlerTest extends TestCase { - - public AttachmentExtractionHandlerTest(String testName) { - super(testName); - } - - public static Test suite() { - return new TestSuite(AttachmentExtractionHandlerTest.class); - } - - public void testHandlerCanBeInstantiated() { - AttachmentExtractionHandler handler = new AttachmentExtractionHandler(); - assertNotNull(handler); - } -} From a7ea29dacbc571fd17a8b8a11da1aaedfa21b5b7 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 21 May 2026 11:05:11 +0200 Subject: [PATCH 09/70] initial Extraction Orchestrator setup --- .gitignore | 8 ++- bookshop/package.json | 9 +--- .../srv/src/main/resources/application.yaml | 3 ++ sap-document-ai/package.json | 9 ++++ sap-document-ai/pom.xml | 52 +++++++++++++++++++ .../sap/cds/configuration/Registration.java | 10 +++- .../cds/handlers/AttachmentEventHandler.java | 19 +++---- .../orchestrator/ExtractionOrchestrator.java | 26 ++++++++-- .../cds/orchestrator/ExtractionService.java | 7 +++ .../sap-document-ai/extraction-job.cds | 19 +++++++ .../cds/com.sap.cds/sap-document-ai/index.cds | 1 + .../sap/cds/AttachmentEventHandlerTest.java | 14 +++-- 12 files changed, 147 insertions(+), 30 deletions(-) create mode 100644 sap-document-ai/package.json create mode 100644 sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java create mode 100644 sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds create mode 100644 sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds diff --git a/.gitignore b/.gitignore index 5f68014..980be25 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,12 @@ replay_pid* # Maven build output target/ +.flattened-pom.xml + +# CDS build output +**/gen/ +**/node_modules/ +**/package-lock.json # IDE -.idea/ +.idea/ \ No newline at end of file diff --git a/bookshop/package.json b/bookshop/package.json index 09871b0..aea6874 100644 --- a/bookshop/package.json +++ b/bookshop/package.json @@ -9,13 +9,8 @@ }, "cds": { "requires": { - "db-ext": { - "[development]": { - "model": "db/sqlite" - }, - "[production]": { - "model": "db/hana" - } + "com.sap.cds/sap-document-ai": { + "model": "com.sap.cds/sap-document-ai" } } } diff --git a/bookshop/srv/src/main/resources/application.yaml b/bookshop/srv/src/main/resources/application.yaml index 1ee2ae9..bfb2a7d 100644 --- a/bookshop/srv/src/main/resources/application.yaml +++ b/bookshop/srv/src/main/resources/application.yaml @@ -7,6 +7,9 @@ spring: sql: init: platform: h2 + h2: + console: + enabled: true cds: security: mock: diff --git a/sap-document-ai/package.json b/sap-document-ai/package.json new file mode 100644 index 0000000..50522de --- /dev/null +++ b/sap-document-ai/package.json @@ -0,0 +1,9 @@ +{ + "name": "sap-document-ai-cds", + "version": "1.0.0", + "description": "CDS model for sap-document-ai plugin", + "license": "ISC", + "devDependencies": { + "@sap/cds-dk": "^9" + } +} diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 56cde7c..95653c4 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -11,6 +11,7 @@ 17 4.6.0 UTF-8 + com.sap.cds.feature.documentai.generated @@ -76,6 +77,57 @@ ${jdk.version} + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + 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 + + + ${generation-package}.cds4j + ${project.basedir}/gen/srv/src/main/resources/edmx/csn.json + + sap.document.ai.** + + + + + + diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java index afb148f..23a58ec 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java @@ -2,13 +2,19 @@ import com.sap.cds.handlers.AttachmentEventHandler; import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.orchestrator.ExtractionService; +import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; public class Registration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - ExtractionOrchestrator extractionOrchestrator = new ExtractionOrchestrator(); - configurer.eventHandler(new AttachmentEventHandler(extractionOrchestrator)); + PersistenceService persistenceService = configurer.getCdsRuntime() + .getServiceCatalog() + .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + ExtractionService extractionService = new ExtractionOrchestrator(persistenceService); + configurer.eventHandler(new AttachmentEventHandler(extractionService)); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index afbaf07..6422cae 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -3,35 +3,30 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; -import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.orchestrator.ExtractionService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/* -* Currently a placeholder handler, to integrate with attachments and test if it is triggered :) -* */ @ServiceName(value = "*", type = AttachmentService.class) public class AttachmentEventHandler implements EventHandler { private static final Logger log = LoggerFactory.getLogger(AttachmentEventHandler.class); - private final ExtractionOrchestrator extractionOrchestrator; + private final ExtractionService extractionService; - public AttachmentEventHandler(ExtractionOrchestrator extractionOrchestrator){ - this.extractionOrchestrator = extractionOrchestrator; + public AttachmentEventHandler(ExtractionService extractionService) { + this.extractionService = extractionService; } @After(event = AttachmentService.EVENT_CREATE_ATTACHMENT) public void afterCreateAttachment(AttachmentCreateEventContext context) { - log.info("[sap-document-ai] After Attachment created: {}", context.getAttachmentIds().get(Attachments.ID)); String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); - log.info("[sap-document-ai] Attachment persisted successfully. Triggering extraction workflow for attachmentId={}", attachmentId); - log.info("[sap-document-ai] attachmentIds={}", context.getAttachmentIds()); - log.info("[sap-document-ai] data={}", context.getData()); - extractionOrchestrator.startExtraction(attachmentId); + String tenantId = context.getUserInfo().getTenant(); + log.info("[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, tenantId={}", attachmentId, tenantId); + extractionService.startExtraction(attachmentId, tenantId); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java index f7ac389..8fdffa5 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java @@ -1,14 +1,34 @@ package com.sap.cds.orchestrator; +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.ExtractionStatus; +import com.sap.cds.ql.Insert; +import com.sap.cds.services.persistence.PersistenceService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class ExtractionOrchestrator { +public class ExtractionOrchestrator implements ExtractionService { private static final Logger logger = LoggerFactory.getLogger(ExtractionOrchestrator.class); - public void startExtraction(String attachmentId) { - logger.info("[sap-document-ai] Orchestrator triggered w/ attachment id {}", attachmentId); + private final PersistenceService persistenceService; + + public ExtractionOrchestrator(PersistenceService persistenceService) { + this.persistenceService = persistenceService; + } + + public void startExtraction(String attachmentId, String tenantId) { + logger.info("[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); + + ExtractionJob job = ExtractionJob.create(); + job.setAttachmentId(attachmentId); + job.setStatus(ExtractionStatus.PENDING); + job.setTenantId(tenantId); + + persistenceService.run(Insert.into(ExtractionJob_.class).entry(job)); + + logger.info("[sap-document-ai] ExtractionJob created with status=Pending for attachmentId={}", attachmentId); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java new file mode 100644 index 0000000..2b04f44 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java @@ -0,0 +1,7 @@ +package com.sap.cds.orchestrator; + +public interface ExtractionService { + + void startExtraction(String attachmentId, String tenantId); + +} diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds new file mode 100644 index 0000000..9b6da27 --- /dev/null +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -0,0 +1,19 @@ +namespace sap.document.ai; + +using { + cuid, + managed +} from '@sap/cds/common'; + +type ExtractionStatus : String enum { + Pending; + Processing; + Completed; + Failed; +} + +entity ExtractionJob : cuid, managed { + attachmentId : String; + status : ExtractionStatus; + tenantId : String; +} diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds new file mode 100644 index 0000000..ffcec72 --- /dev/null +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds @@ -0,0 +1 @@ +using from './extraction-job'; diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index f13808c..e6e3473 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -2,7 +2,8 @@ import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.handlers.AttachmentEventHandler; -import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.orchestrator.ExtractionService; +import com.sap.cds.services.request.UserInfo; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -17,16 +18,19 @@ class AttachmentEventHandlerTest { @Mock - ExtractionOrchestrator extractionOrchestrator; + ExtractionService extractionService; @Test - void afterCreateAttachment_triggersOrchestration() { - AttachmentEventHandler handler = new AttachmentEventHandler(extractionOrchestrator); + void afterCreateAttachment_triggersOrchestrationWithTenant() { + AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); assertNotNull(handler); AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); + UserInfo userInfo = mock(UserInfo.class); when(context.getAttachmentIds()).thenReturn(Map.of("ID", "test-attachment-id")); + when(context.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn("test-tenant"); handler.afterCreateAttachment(context); - verify(extractionOrchestrator).startExtraction("test-attachment-id"); + verify(extractionService).startExtraction("test-attachment-id", "test-tenant"); } } From a37e5fd2fe6c2d162742c1107ec1e112cf55534a Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 21 May 2026 12:54:58 +0200 Subject: [PATCH 10/70] async process :) --- .../orchestrator/ExtractionOrchestrator.java | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java index 8fdffa5..4762174 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java @@ -1,13 +1,17 @@ package com.sap.cds.orchestrator; +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.generated.cds4j.sap.document.ai.ExtractionStatus; import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Update; import com.sap.cds.services.persistence.PersistenceService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.concurrent.CompletableFuture; + public class ExtractionOrchestrator implements ExtractionService { private static final Logger logger = LoggerFactory.getLogger(ExtractionOrchestrator.class); @@ -26,9 +30,33 @@ public void startExtraction(String attachmentId, String tenantId) { job.setStatus(ExtractionStatus.PENDING); job.setTenantId(tenantId); - persistenceService.run(Insert.into(ExtractionJob_.class).entry(job)); + 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 for attachmentId={}", attachmentId); + + CompletableFuture.runAsync(() -> { + try { + // assuming process has begun + Thread.sleep(3000); + updateStatus(jobId, ExtractionStatus.PROCESSING); + + // fake orchestration for now + // assuming process has finished / failed + Thread.sleep(3000); + updateStatus(jobId, ExtractionStatus.COMPLETED); + } catch (Exception e) { + updateStatus(jobId, ExtractionStatus.FAILED); + } + }); } + private void updateStatus(String jobId, String status) { + ExtractionJob extractionJob = ExtractionJob.create(); + extractionJob.setStatus(status); + persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); + logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); + } + + } From 055a65913a39cb2a23a8d5a780f82b3baa9f2a4b Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 21 May 2026 14:06:58 +0200 Subject: [PATCH 11/70] add tests --- sap-document-ai/pom.xml | 12 +++ .../cds/handlers/AttachmentEventHandler.java | 4 + .../orchestrator/ExtractionOrchestrator.java | 37 ++++--- .../sap-document-ai/extraction-job.cds | 2 +- .../sap/cds/AttachmentEventHandlerTest.java | 24 ++++- .../sap/cds/ExtractionOrchestratorTest.java | 100 ++++++++++++++++++ 6 files changed, 160 insertions(+), 19 deletions(-) create mode 100644 sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 95653c4..fe36aa1 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -66,6 +66,18 @@ 5.12.0 test + + org.awaitility + awaitility + 4.2.2 + test + + + org.assertj + assertj-core + 3.26.3 + test + diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 6422cae..89ec194 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -25,6 +25,10 @@ public AttachmentEventHandler(ExtractionService extractionService) { public void afterCreateAttachment(AttachmentCreateEventContext context) { String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); String tenantId = context.getUserInfo().getTenant(); + if (attachmentId == null) { + log.warn("[sap-document-ai] attachmentId is null, skipping extraction"); + return; + } log.info("[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, tenantId={}", attachmentId, tenantId); extractionService.startExtraction(attachmentId, tenantId); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java index 4762174..128195d 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java @@ -24,33 +24,39 @@ public ExtractionOrchestrator(PersistenceService persistenceService) { public void startExtraction(String attachmentId, String tenantId) { logger.info("[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); - - ExtractionJob job = ExtractionJob.create(); - job.setAttachmentId(attachmentId); - job.setStatus(ExtractionStatus.PENDING); - job.setTenantId(tenantId); - - 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 for attachmentId={}", attachmentId); + String jobId = createExtractionJob(attachmentId, tenantId); CompletableFuture.runAsync(() -> { try { - // assuming process has begun + //TODO: Thread.sleep is currently for simulation purposes only. Remove it once real service in place. Thread.sleep(3000); updateStatus(jobId, ExtractionStatus.PROCESSING); - - // fake orchestration for now - // assuming process has finished / failed - Thread.sleep(3000); + processDocument(jobId); updateStatus(jobId, ExtractionStatus.COMPLETED); } catch (Exception e) { updateStatus(jobId, ExtractionStatus.FAILED); + Thread.currentThread().interrupt(); } }); } + private String createExtractionJob(String attachmentId, String tenantId) { + ExtractionJob job = ExtractionJob.create(); + job.setAttachmentId(attachmentId); + job.setTenantId(tenantId); + + 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 for attachmentId={} & jobId={}", attachmentId, jobId); + return jobId; + } + + //TODO: real implementation for processing a document will be here sooon + private static void processDocument(String jobId) throws InterruptedException { + logger.info("[sap-document-ai] Simulating document processing for jobId={}", jobId); + Thread.sleep(3000); + } + private void updateStatus(String jobId, String status) { ExtractionJob extractionJob = ExtractionJob.create(); extractionJob.setStatus(status); @@ -58,5 +64,4 @@ private void updateStatus(String jobId, String status) { logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); } - } diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index 9b6da27..5a91481 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -14,6 +14,6 @@ type ExtractionStatus : String enum { entity ExtractionJob : cuid, managed { attachmentId : String; - status : ExtractionStatus; + status : ExtractionStatus default #Pending; tenantId : String; } diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index e6e3473..bfd56b1 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -11,8 +11,8 @@ import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; @ExtendWith(MockitoExtension.class) class AttachmentEventHandlerTest { @@ -22,15 +22,35 @@ class AttachmentEventHandlerTest { @Test void afterCreateAttachment_triggersOrchestrationWithTenant() { + // Arrange AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); - assertNotNull(handler); AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); UserInfo userInfo = mock(UserInfo.class); when(context.getAttachmentIds()).thenReturn(Map.of("ID", "test-attachment-id")); when(context.getUserInfo()).thenReturn(userInfo); when(userInfo.getTenant()).thenReturn("test-tenant"); + + // Act handler.afterCreateAttachment(context); + + // Assert verify(extractionService).startExtraction("test-attachment-id", "test-tenant"); } + @Test + void afterCreateAttachment_skipsExtractionWhenAttachmentIdIsNull() { + // Arrange + AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); + AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); + UserInfo userInfo = mock(UserInfo.class); + when(context.getAttachmentIds()).thenReturn(Map.of()); + when(context.getUserInfo()).thenReturn(userInfo); + + // Act + handler.afterCreateAttachment(context); + + // Assert + verify(extractionService, never()).startExtraction(any(), any()); + } + } diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java new file mode 100644 index 0000000..972a797 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java @@ -0,0 +1,100 @@ +package com.sap.cds; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; +import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.ql.cqn.CqnInsert; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.services.persistence.PersistenceService; +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; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ExtractionOrchestratorTest { + + @Mock + PersistenceService persistenceService; + + @Mock + Result insertResult; + + ExtractionOrchestrator orchestrator; + + @BeforeEach + void setUp() { + ExtractionJob createdJob = ExtractionJob.create(); + createdJob.setId("test-job-id"); + when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); + when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); + lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + orchestrator = new ExtractionOrchestrator(persistenceService); + } + + @Test + void startExtraction_createsOneJobWithCorrectFields() { + // Arrange + String attachmentId = "att-123"; + String tenantId = "tenant-1"; + + // Act + orchestrator.startExtraction(attachmentId, tenantId); + + // Assert + ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); + verify(persistenceService, times(1)).run(insertCaptor.capture()); + ExtractionJob inserted = Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); + assertThat(inserted.getAttachmentId()).isEqualTo(attachmentId); + assertThat(inserted.getTenantId()).isEqualTo(tenantId); + } + + @Test + void startExtraction_transitionsStatusToProcessingThenCompleted() { + // Arrange + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + + // Act + orchestrator.startExtraction("att-123", "tenant-1"); + + // Assert + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> + verify(persistenceService, times(2)).run(updateCaptor.capture()) + ); + List updates = updateCaptor.getAllValues(); + assertThat(Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) + .isEqualTo(ExtractionStatus.PROCESSING); + assertThat(Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) + .isEqualTo(ExtractionStatus.COMPLETED); + } + + @Test + void startExtraction_transitionsStatusToFailedWhenProcessingThrows() { + // Arrange + when(persistenceService.run(any(CqnUpdate.class))) + .thenThrow(new RuntimeException("simulated processing failure")); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + + // Act + orchestrator.startExtraction("att-123", "tenant-1"); + + // Assert + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> + verify(persistenceService, atLeastOnce()).run(updateCaptor.capture()) + ); + List updates = updateCaptor.getAllValues(); + ExtractionJob lastUpdate = Struct.access(updates.get(updates.size() - 1).entries().get(0)).as(ExtractionJob.class); + assertThat(lastUpdate.getStatus()).isEqualTo(ExtractionStatus.FAILED); + } + +} From e8e1ee97945d2dcc1b208b0f3f0b198c9090c563 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 21 May 2026 14:25:14 +0200 Subject: [PATCH 12/70] bounded thread pool --- .../orchestrator/ExtractionOrchestrator.java | 22 ++++++++++++------- .../sap-document-ai/extraction-job.cds | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java index 128195d..ef2443e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java @@ -11,10 +11,14 @@ import org.slf4j.LoggerFactory; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class ExtractionOrchestrator implements ExtractionService { private static final Logger logger = LoggerFactory.getLogger(ExtractionOrchestrator.class); + private static final int MAX_PARALLEL_EXTRACTIONS = Runtime.getRuntime().availableProcessors(); + private static final ExecutorService executor = Executors.newFixedThreadPool(MAX_PARALLEL_EXTRACTIONS); private final PersistenceService persistenceService; @@ -33,11 +37,13 @@ public void startExtraction(String attachmentId, String tenantId) { updateStatus(jobId, ExtractionStatus.PROCESSING); processDocument(jobId); updateStatus(jobId, ExtractionStatus.COMPLETED); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + updateStatus(jobId, ExtractionStatus.FAILED); } catch (Exception e) { updateStatus(jobId, ExtractionStatus.FAILED); - Thread.currentThread().interrupt(); } - }); + }, executor); } private String createExtractionJob(String attachmentId, String tenantId) { @@ -51,17 +57,17 @@ private String createExtractionJob(String attachmentId, String tenantId) { return jobId; } - //TODO: real implementation for processing a document will be here sooon - private static void processDocument(String jobId) throws InterruptedException { + //TODO: real implementation for processing a document will be here soon + private void processDocument(String jobId) throws InterruptedException { logger.info("[sap-document-ai] Simulating document processing for jobId={}", jobId); Thread.sleep(3000); } private void updateStatus(String jobId, String status) { - ExtractionJob extractionJob = ExtractionJob.create(); - extractionJob.setStatus(status); - persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); - logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); + ExtractionJob extractionJob = ExtractionJob.create(); + extractionJob.setStatus(status); + persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); + logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); } } diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index 5a91481..2473643 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -12,6 +12,7 @@ type ExtractionStatus : String enum { Failed; } +@assert.unique: { attachmentId: [attachmentId] } entity ExtractionJob : cuid, managed { attachmentId : String; status : ExtractionStatus default #Pending; From 84034c6baa86fcc10287dfd381d5ba3fe0d42dd9 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 27 May 2026 09:36:17 +0200 Subject: [PATCH 13/70] review fixes --- ...egistration.java => AttachmentEventHandlerRegistration.java} | 2 +- .../com.sap.cds.services.runtime.CdsRuntimeConfiguration | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename sap-document-ai/src/main/java/com/sap/cds/configuration/{Registration.java => AttachmentEventHandlerRegistration.java} (80%) diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java similarity index 80% rename from sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java rename to sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index ee2d391..7b44f24 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -4,7 +4,7 @@ import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; -public class Registration implements CdsRuntimeConfiguration { +public class AttachmentEventHandlerRegistration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler(new AttachmentEventHandler()); diff --git a/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration index 62d332d..733bb55 100644 --- a/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration +++ b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -1 +1 @@ -com.sap.cds.configuration.Registration \ No newline at end of file +com.sap.cds.configuration.AttachmentEventHandlerRegistration \ No newline at end of file From 8a3e5f3628e18bf7f6daae30d4c077c69b222467 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 27 May 2026 10:51:47 +0200 Subject: [PATCH 14/70] revert unintended change --- .../AttachmentEventHandlerRegistration.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index 7b44f24..3580173 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -1,12 +1,20 @@ package com.sap.cds.configuration; import com.sap.cds.handlers.AttachmentEventHandler; +import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.orchestrator.ExtractionService; +import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; public class AttachmentEventHandlerRegistration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - configurer.eventHandler(new AttachmentEventHandler()); + PersistenceService persistenceService = configurer.getCdsRuntime() + .getServiceCatalog() + .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + ExtractionService extractionService = new ExtractionOrchestrator(persistenceService); + configurer.eventHandler(new AttachmentEventHandler(extractionService)); } } From 7dd4e78aa5d91b4c48c055115ea6206fb43bc5cf Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 27 May 2026 11:46:36 +0200 Subject: [PATCH 15/70] review fixes --- bookshop/srv/pom.xml | 17 ----------------- sap-document-ai/package.json | 9 --------- sap-document-ai/pom.xml | 17 ----------------- .../AttachmentEventHandlerRegistration.java | 6 +++--- .../cds/handlers/AttachmentEventHandler.java | 6 +++--- .../ExtractionService.java | 2 +- .../ExtractionServiceImpl.java} | 8 ++++---- .../com/sap/cds/AttachmentEventHandlerTest.java | 2 +- ...Test.java => ExtractionServiceImplTest.java} | 8 ++++---- 9 files changed, 16 insertions(+), 59 deletions(-) delete mode 100644 sap-document-ai/package.json rename sap-document-ai/src/main/java/com/sap/cds/{orchestrator => service}/ExtractionService.java (75%) rename sap-document-ai/src/main/java/com/sap/cds/{orchestrator/ExtractionOrchestrator.java => service/ExtractionServiceImpl.java} (93%) rename sap-document-ai/src/test/java/com/sap/cds/{ExtractionOrchestratorTest.java => ExtractionServiceImplTest.java} (94%) diff --git a/bookshop/srv/pom.xml b/bookshop/srv/pom.xml index 1a057ac..31abea2 100644 --- a/bookshop/srv/pom.xml +++ b/bookshop/srv/pom.xml @@ -97,23 +97,6 @@ - - cds.install-node - - install-node - - - - - cds.npm-ci - - npm - - - ci - - - cds.resolve diff --git a/sap-document-ai/package.json b/sap-document-ai/package.json deleted file mode 100644 index 50522de..0000000 --- a/sap-document-ai/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "sap-document-ai-cds", - "version": "1.0.0", - "description": "CDS model for sap-document-ai plugin", - "license": "ISC", - "devDependencies": { - "@sap/cds-dk": "^9" - } -} diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index fe36aa1..20619a8 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -94,23 +94,6 @@ cds-maven-plugin ${cds.services.version} - - cds.install-node - - install-node - - - - - cds.npm-ci - - npm - - - ci - - - cds.build diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index 3580173..d7e8d90 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -1,8 +1,8 @@ package com.sap.cds.configuration; import com.sap.cds.handlers.AttachmentEventHandler; -import com.sap.cds.orchestrator.ExtractionOrchestrator; -import com.sap.cds.orchestrator.ExtractionService; +import com.sap.cds.service.ExtractionServiceImpl; +import com.sap.cds.service.ExtractionService; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -14,7 +14,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { .getServiceCatalog() .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - ExtractionService extractionService = new ExtractionOrchestrator(persistenceService); + ExtractionService extractionService = new ExtractionServiceImpl(persistenceService); configurer.eventHandler(new AttachmentEventHandler(extractionService)); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 4664d52..c9cedfe 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -3,7 +3,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; -import com.sap.cds.orchestrator.ExtractionService; +import com.sap.cds.service.ExtractionService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; @@ -27,9 +27,9 @@ public void afterCreateAttachment(AttachmentCreateEventContext context) { String tenantId = context.getUserInfo().getTenant(); if (attachmentId == null) { log.warn("[sap-document-ai] attachmentId is null, skipping extraction"); + return; } - log.info("[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, tenantId={}", - attachmentId, tenantId); + log.info("[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, tenantId={}", attachmentId, tenantId); extractionService.startExtraction(attachmentId, tenantId); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java similarity index 75% rename from sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java rename to sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index 2b04f44..4ff83e6 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -1,4 +1,4 @@ -package com.sap.cds.orchestrator; +package com.sap.cds.service; public interface ExtractionService { diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java similarity index 93% rename from sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java rename to sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index ef2443e..d639c8b 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -1,4 +1,4 @@ -package com.sap.cds.orchestrator; +package com.sap.cds.service; import com.sap.cds.Result; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; @@ -14,15 +14,15 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -public class ExtractionOrchestrator implements ExtractionService { +public class ExtractionServiceImpl implements ExtractionService { - private static final Logger logger = LoggerFactory.getLogger(ExtractionOrchestrator.class); + private static final Logger logger = LoggerFactory.getLogger(ExtractionServiceImpl.class); private static final int MAX_PARALLEL_EXTRACTIONS = Runtime.getRuntime().availableProcessors(); private static final ExecutorService executor = Executors.newFixedThreadPool(MAX_PARALLEL_EXTRACTIONS); private final PersistenceService persistenceService; - public ExtractionOrchestrator(PersistenceService persistenceService) { + public ExtractionServiceImpl(PersistenceService persistenceService) { this.persistenceService = persistenceService; } diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index bfd56b1..8dec26e 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -2,7 +2,7 @@ import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.handlers.AttachmentEventHandler; -import com.sap.cds.orchestrator.ExtractionService; +import com.sap.cds.service.ExtractionService; import com.sap.cds.services.request.UserInfo; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java similarity index 94% rename from sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java rename to sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java index 972a797..49ac12f 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java @@ -2,7 +2,7 @@ import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; -import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.service.ExtractionServiceImpl; import com.sap.cds.ql.cqn.CqnInsert; import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.services.persistence.PersistenceService; @@ -22,7 +22,7 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -class ExtractionOrchestratorTest { +class ExtractionServiceImplTest { @Mock PersistenceService persistenceService; @@ -30,7 +30,7 @@ class ExtractionOrchestratorTest { @Mock Result insertResult; - ExtractionOrchestrator orchestrator; + ExtractionServiceImpl orchestrator; @BeforeEach void setUp() { @@ -39,7 +39,7 @@ void setUp() { when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); - orchestrator = new ExtractionOrchestrator(persistenceService); + orchestrator = new ExtractionServiceImpl(persistenceService); } @Test From 5ee9f673694cf293f6886dfe581e97c3ea89b12d Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 27 May 2026 11:50:48 +0200 Subject: [PATCH 16/70] review fixes --- .../sap/cds/configuration/Registration.java | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java deleted file mode 100644 index 23a58ec..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.sap.cds.configuration; - -import com.sap.cds.handlers.AttachmentEventHandler; -import com.sap.cds.orchestrator.ExtractionOrchestrator; -import com.sap.cds.orchestrator.ExtractionService; -import com.sap.cds.services.persistence.PersistenceService; -import com.sap.cds.services.runtime.CdsRuntimeConfiguration; -import com.sap.cds.services.runtime.CdsRuntimeConfigurer; - -public class Registration implements CdsRuntimeConfiguration { - @Override - public void eventHandlers(CdsRuntimeConfigurer configurer) { - PersistenceService persistenceService = configurer.getCdsRuntime() - .getServiceCatalog() - .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - - ExtractionService extractionService = new ExtractionOrchestrator(persistenceService); - configurer.eventHandler(new AttachmentEventHandler(extractionService)); - } -} From 8319198ad62ad12d1730e55c6035534c87caefdf Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 28 May 2026 13:27:19 +0200 Subject: [PATCH 17/70] make the working directory path absolute --- sap-document-ai/pom.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 20619a8..7573bc1 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -100,9 +100,8 @@ cds - ./src/main/resources/cds/com.sap.cds/sap-document-ai - build --for java --src ./ --dest ../../../../../../../gen/srv + build --for java --src ${project.basedir}/src/main/resources/cds/com.sap.cds/sap-document-ai --dest ${project.basedir}/gen/srv From d54be4a9f23788a544c3f1e1a06802cfb24e3e88 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 13 May 2026 11:55:15 +0200 Subject: [PATCH 18/70] enable attachments remove extra files rename handler tiny fixes setup orchestrator initial Extraction Orchestrator setup async process :) add tests bounded thread pool # Conflicts: # sap-document-ai/pom.xml # sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java # sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration # sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java --- sap-document-ai/package.json | 9 ++ sap-document-ai/pom.xml | 51 +++++++++ .../sap/cds/configuration/Registration.java | 20 ++++ .../cds/handlers/AttachmentEventHandler.java | 1 - .../orchestrator/ExtractionOrchestrator.java | 73 +++++++++++++ .../cds/orchestrator/ExtractionService.java | 7 ++ .../sap/cds/ExtractionOrchestratorTest.java | 100 ++++++++++++++++++ 7 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 sap-document-ai/package.json create mode 100644 sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java diff --git a/sap-document-ai/package.json b/sap-document-ai/package.json new file mode 100644 index 0000000..50522de --- /dev/null +++ b/sap-document-ai/package.json @@ -0,0 +1,9 @@ +{ + "name": "sap-document-ai-cds", + "version": "1.0.0", + "description": "CDS model for sap-document-ai plugin", + "license": "ISC", + "devDependencies": { + "@sap/cds-dk": "^9" + } +} diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 7573bc1..65adc04 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -89,6 +89,57 @@ ${jdk.version} + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + 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 + + + ${generation-package}.cds4j + ${project.basedir}/gen/srv/src/main/resources/edmx/csn.json + + sap.document.ai.** + + + + + + com.sap.cds cds-maven-plugin diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java new file mode 100644 index 0000000..23a58ec --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java @@ -0,0 +1,20 @@ +package com.sap.cds.configuration; + +import com.sap.cds.handlers.AttachmentEventHandler; +import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.orchestrator.ExtractionService; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; + +public class Registration implements CdsRuntimeConfiguration { + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + PersistenceService persistenceService = configurer.getCdsRuntime() + .getServiceCatalog() + .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + ExtractionService extractionService = new ExtractionOrchestrator(persistenceService); + configurer.eventHandler(new AttachmentEventHandler(extractionService)); + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index c9cedfe..68988f3 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -8,7 +8,6 @@ import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @ServiceName(value = "*", type = AttachmentService.class) public class AttachmentEventHandler implements EventHandler { diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java new file mode 100644 index 0000000..ef2443e --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java @@ -0,0 +1,73 @@ +package com.sap.cds.orchestrator; + +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.generated.cds4j.sap.document.ai.ExtractionStatus; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Update; +import com.sap.cds.services.persistence.PersistenceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class ExtractionOrchestrator implements ExtractionService { + + private static final Logger logger = LoggerFactory.getLogger(ExtractionOrchestrator.class); + private static final int MAX_PARALLEL_EXTRACTIONS = Runtime.getRuntime().availableProcessors(); + private static final ExecutorService executor = Executors.newFixedThreadPool(MAX_PARALLEL_EXTRACTIONS); + + private final PersistenceService persistenceService; + + public ExtractionOrchestrator(PersistenceService persistenceService) { + this.persistenceService = persistenceService; + } + + public void startExtraction(String attachmentId, String tenantId) { + logger.info("[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); + String jobId = createExtractionJob(attachmentId, tenantId); + + CompletableFuture.runAsync(() -> { + try { + //TODO: Thread.sleep is currently for simulation purposes only. Remove it once real service in place. + Thread.sleep(3000); + updateStatus(jobId, ExtractionStatus.PROCESSING); + processDocument(jobId); + updateStatus(jobId, ExtractionStatus.COMPLETED); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + updateStatus(jobId, ExtractionStatus.FAILED); + } catch (Exception e) { + updateStatus(jobId, ExtractionStatus.FAILED); + } + }, executor); + } + + private String createExtractionJob(String attachmentId, String tenantId) { + ExtractionJob job = ExtractionJob.create(); + job.setAttachmentId(attachmentId); + job.setTenantId(tenantId); + + 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 for attachmentId={} & jobId={}", attachmentId, jobId); + return jobId; + } + + //TODO: real implementation for processing a document will be here soon + private void processDocument(String jobId) throws InterruptedException { + logger.info("[sap-document-ai] Simulating document processing for jobId={}", jobId); + Thread.sleep(3000); + } + + private void updateStatus(String jobId, String status) { + ExtractionJob extractionJob = ExtractionJob.create(); + extractionJob.setStatus(status); + persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); + logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); + } + +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java new file mode 100644 index 0000000..2b04f44 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java @@ -0,0 +1,7 @@ +package com.sap.cds.orchestrator; + +public interface ExtractionService { + + void startExtraction(String attachmentId, String tenantId); + +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java new file mode 100644 index 0000000..972a797 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java @@ -0,0 +1,100 @@ +package com.sap.cds; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; +import com.sap.cds.orchestrator.ExtractionOrchestrator; +import com.sap.cds.ql.cqn.CqnInsert; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.services.persistence.PersistenceService; +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; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ExtractionOrchestratorTest { + + @Mock + PersistenceService persistenceService; + + @Mock + Result insertResult; + + ExtractionOrchestrator orchestrator; + + @BeforeEach + void setUp() { + ExtractionJob createdJob = ExtractionJob.create(); + createdJob.setId("test-job-id"); + when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); + when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); + lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + orchestrator = new ExtractionOrchestrator(persistenceService); + } + + @Test + void startExtraction_createsOneJobWithCorrectFields() { + // Arrange + String attachmentId = "att-123"; + String tenantId = "tenant-1"; + + // Act + orchestrator.startExtraction(attachmentId, tenantId); + + // Assert + ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); + verify(persistenceService, times(1)).run(insertCaptor.capture()); + ExtractionJob inserted = Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); + assertThat(inserted.getAttachmentId()).isEqualTo(attachmentId); + assertThat(inserted.getTenantId()).isEqualTo(tenantId); + } + + @Test + void startExtraction_transitionsStatusToProcessingThenCompleted() { + // Arrange + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + + // Act + orchestrator.startExtraction("att-123", "tenant-1"); + + // Assert + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> + verify(persistenceService, times(2)).run(updateCaptor.capture()) + ); + List updates = updateCaptor.getAllValues(); + assertThat(Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) + .isEqualTo(ExtractionStatus.PROCESSING); + assertThat(Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) + .isEqualTo(ExtractionStatus.COMPLETED); + } + + @Test + void startExtraction_transitionsStatusToFailedWhenProcessingThrows() { + // Arrange + when(persistenceService.run(any(CqnUpdate.class))) + .thenThrow(new RuntimeException("simulated processing failure")); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + + // Act + orchestrator.startExtraction("att-123", "tenant-1"); + + // Assert + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> + verify(persistenceService, atLeastOnce()).run(updateCaptor.capture()) + ); + List updates = updateCaptor.getAllValues(); + ExtractionJob lastUpdate = Struct.access(updates.get(updates.size() - 1).entries().get(0)).as(ExtractionJob.class); + assertThat(lastUpdate.getStatus()).isEqualTo(ExtractionStatus.FAILED); + } + +} From aaba3816120ad4b5905a4a98d0d1209158861a68 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 22 May 2026 18:23:23 +0200 Subject: [PATCH 19/70] attachment content retrieval clean up --- bookshop/srv/attachments.cds | 4 + sap-document-ai/pom.xml | 55 +--------- .../AttachmentEventHandlerRegistration.java | 14 ++- .../sap/cds/configuration/Registration.java | 20 ---- .../cds/handlers/AttachmentEventHandler.java | 12 ++- .../orchestrator/ExtractionOrchestrator.java | 73 ------------- .../cds/orchestrator/ExtractionService.java | 7 -- .../DefaultDocumentAiProcessingService.java | 26 +++++ .../service/DocumentAiProcessingService.java | 9 ++ .../sap/cds/service/ExtractionService.java | 4 +- .../cds/service/ExtractionServiceImpl.java | 24 ++--- .../sap/cds/AttachmentEventHandlerTest.java | 12 ++- .../sap/cds/ExtractionOrchestratorTest.java | 100 ------------------ .../sap/cds/ExtractionServiceImplTest.java | 21 ++-- 14 files changed, 96 insertions(+), 285 deletions(-) delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java delete mode 100644 sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java diff --git a/bookshop/srv/attachments.cds b/bookshop/srv/attachments.cds index 32335bf..69a1717 100644 --- a/bookshop/srv/attachments.cds +++ b/bookshop/srv/attachments.cds @@ -24,3 +24,7 @@ annotate adminService.Books with @(UI.Facets: [{ Label : '{i18n>attachments}', Target: 'attachments/@UI.LineItem' }]); + +service nonDraftService { + entity Books as projection on my.Books; +} diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 65adc04..9b7c96c 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -1,5 +1,5 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> 4.0.0 com.sap.cds sap-document-ai @@ -89,57 +89,6 @@ ${jdk.version} - - com.sap.cds - cds-maven-plugin - ${cds.services.version} - - - 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 - - - ${generation-package}.cds4j - ${project.basedir}/gen/srv/src/main/resources/edmx/csn.json - - sap.document.ai.** - - - - - - com.sap.cds cds-maven-plugin @@ -175,4 +124,4 @@ - + \ No newline at end of file diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index d7e8d90..9c5ede1 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -1,8 +1,11 @@ package com.sap.cds.configuration; import com.sap.cds.handlers.AttachmentEventHandler; -import com.sap.cds.service.ExtractionServiceImpl; +import com.sap.cds.service.DefaultDocumentAiProcessingService; +import com.sap.cds.service.DocumentAiProcessingService; import com.sap.cds.service.ExtractionService; +import com.sap.cds.service.ExtractionServiceImpl; +import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -10,11 +13,12 @@ public class AttachmentEventHandlerRegistration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - PersistenceService persistenceService = configurer.getCdsRuntime() - .getServiceCatalog() + ServiceCatalog serviceCatalog = configurer.getCdsRuntime() + .getServiceCatalog(); + PersistenceService persistenceService = serviceCatalog .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - - ExtractionService extractionService = new ExtractionServiceImpl(persistenceService); + DocumentAiProcessingService documentAiProcessingService = new DefaultDocumentAiProcessingService(); + ExtractionService extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); configurer.eventHandler(new AttachmentEventHandler(extractionService)); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java deleted file mode 100644 index 23a58ec..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/Registration.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.sap.cds.configuration; - -import com.sap.cds.handlers.AttachmentEventHandler; -import com.sap.cds.orchestrator.ExtractionOrchestrator; -import com.sap.cds.orchestrator.ExtractionService; -import com.sap.cds.services.persistence.PersistenceService; -import com.sap.cds.services.runtime.CdsRuntimeConfiguration; -import com.sap.cds.services.runtime.CdsRuntimeConfigurer; - -public class Registration implements CdsRuntimeConfiguration { - @Override - public void eventHandlers(CdsRuntimeConfigurer configurer) { - PersistenceService persistenceService = configurer.getCdsRuntime() - .getServiceCatalog() - .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - - ExtractionService extractionService = new ExtractionOrchestrator(persistenceService); - configurer.eventHandler(new AttachmentEventHandler(extractionService)); - } -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 68988f3..8721672 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -8,6 +8,9 @@ import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; @ServiceName(value = "*", type = AttachmentService.class) public class AttachmentEventHandler implements EventHandler { @@ -24,12 +27,17 @@ public AttachmentEventHandler(ExtractionService extractionService) { public void afterCreateAttachment(AttachmentCreateEventContext context) { String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); String tenantId = context.getUserInfo().getTenant(); + if (attachmentId == null) { log.warn("[sap-document-ai] attachmentId is null, skipping extraction"); return; } - log.info("[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, tenantId={}", attachmentId, tenantId); - extractionService.startExtraction(attachmentId, tenantId); + + String contentId = context.getContentId(); + InputStream content = context.getData().getContent(); + + log.info("[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, contentId={}, tenantId={}", attachmentId, contentId, tenantId); + extractionService.startExtraction(attachmentId, contentId, tenantId, content); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java deleted file mode 100644 index ef2443e..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionOrchestrator.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.sap.cds.orchestrator; - -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.generated.cds4j.sap.document.ai.ExtractionStatus; -import com.sap.cds.ql.Insert; -import com.sap.cds.ql.Update; -import com.sap.cds.services.persistence.PersistenceService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class ExtractionOrchestrator implements ExtractionService { - - private static final Logger logger = LoggerFactory.getLogger(ExtractionOrchestrator.class); - private static final int MAX_PARALLEL_EXTRACTIONS = Runtime.getRuntime().availableProcessors(); - private static final ExecutorService executor = Executors.newFixedThreadPool(MAX_PARALLEL_EXTRACTIONS); - - private final PersistenceService persistenceService; - - public ExtractionOrchestrator(PersistenceService persistenceService) { - this.persistenceService = persistenceService; - } - - public void startExtraction(String attachmentId, String tenantId) { - logger.info("[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); - String jobId = createExtractionJob(attachmentId, tenantId); - - CompletableFuture.runAsync(() -> { - try { - //TODO: Thread.sleep is currently for simulation purposes only. Remove it once real service in place. - Thread.sleep(3000); - updateStatus(jobId, ExtractionStatus.PROCESSING); - processDocument(jobId); - updateStatus(jobId, ExtractionStatus.COMPLETED); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - updateStatus(jobId, ExtractionStatus.FAILED); - } catch (Exception e) { - updateStatus(jobId, ExtractionStatus.FAILED); - } - }, executor); - } - - private String createExtractionJob(String attachmentId, String tenantId) { - ExtractionJob job = ExtractionJob.create(); - job.setAttachmentId(attachmentId); - job.setTenantId(tenantId); - - 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 for attachmentId={} & jobId={}", attachmentId, jobId); - return jobId; - } - - //TODO: real implementation for processing a document will be here soon - private void processDocument(String jobId) throws InterruptedException { - logger.info("[sap-document-ai] Simulating document processing for jobId={}", jobId); - Thread.sleep(3000); - } - - private void updateStatus(String jobId, String status) { - ExtractionJob extractionJob = ExtractionJob.create(); - extractionJob.setStatus(status); - persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); - logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); - } - -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java deleted file mode 100644 index 2b04f44..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/orchestrator/ExtractionService.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.sap.cds.orchestrator; - -public interface ExtractionService { - - void startExtraction(String attachmentId, String tenantId); - -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java new file mode 100644 index 0000000..9ffb8cc --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -0,0 +1,26 @@ +package com.sap.cds.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; + +public class DefaultDocumentAiProcessingService implements DocumentAiProcessingService { + + private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiProcessingService.class); + + @Override + public void processDocument(String jobId, InputStream content) { + logger.info("[sap-document-ai] Processing document for jobId={} and content={}", jobId, content); + try { + // TODO: Replace mock delay with real Document AI integration + Thread.sleep(3000); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("[sap-document-ai] Interrupted during extraction for jobId={}", jobId, e); + } catch (Exception e) { + logger.error("[sap-document-ai] Extraction failed for jobId={}", jobId, e); + } + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java new file mode 100644 index 0000000..4f28680 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java @@ -0,0 +1,9 @@ +package com.sap.cds.service; + +import java.io.InputStream; + +public interface DocumentAiProcessingService { + + void processDocument(String jobId, InputStream content); + +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index 4ff83e6..b62885f 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -1,7 +1,9 @@ package com.sap.cds.service; +import java.io.InputStream; + public interface ExtractionService { - void startExtraction(String attachmentId, String tenantId); + void startExtraction(String attachmentId, String contentId, String tenantId, InputStream content); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index d639c8b..6dd1032 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -10,6 +10,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.InputStream; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -21,25 +22,23 @@ public class ExtractionServiceImpl implements ExtractionService { private static final ExecutorService executor = Executors.newFixedThreadPool(MAX_PARALLEL_EXTRACTIONS); private final PersistenceService persistenceService; + private final DocumentAiProcessingService documentAiProcessingService; - public ExtractionServiceImpl(PersistenceService persistenceService) { + public ExtractionServiceImpl(PersistenceService persistenceService, DocumentAiProcessingService documentAiProcessingService) { this.persistenceService = persistenceService; + this.documentAiProcessingService = documentAiProcessingService; } - public void startExtraction(String attachmentId, String tenantId) { + @Override + public void startExtraction(String attachmentId, String contentId, String tenantId, InputStream content) { logger.info("[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); String jobId = createExtractionJob(attachmentId, tenantId); CompletableFuture.runAsync(() -> { try { - //TODO: Thread.sleep is currently for simulation purposes only. Remove it once real service in place. - Thread.sleep(3000); updateStatus(jobId, ExtractionStatus.PROCESSING); - processDocument(jobId); + documentAiProcessingService.processDocument(jobId, content); updateStatus(jobId, ExtractionStatus.COMPLETED); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - updateStatus(jobId, ExtractionStatus.FAILED); } catch (Exception e) { updateStatus(jobId, ExtractionStatus.FAILED); } @@ -57,17 +56,10 @@ private String createExtractionJob(String attachmentId, String tenantId) { return jobId; } - //TODO: real implementation for processing a document will be here soon - private void processDocument(String jobId) throws InterruptedException { - logger.info("[sap-document-ai] Simulating document processing for jobId={}", jobId); - Thread.sleep(3000); - } - private void updateStatus(String jobId, String status) { ExtractionJob extractionJob = ExtractionJob.create(); extractionJob.setStatus(status); persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); } - -} + } diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index 8dec26e..70914c0 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -1,5 +1,6 @@ package com.sap.cds; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.handlers.AttachmentEventHandler; import com.sap.cds.service.ExtractionService; @@ -9,6 +10,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.util.Map; import static org.mockito.Mockito.*; @@ -26,15 +29,20 @@ void afterCreateAttachment_triggersOrchestrationWithTenant() { AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); UserInfo userInfo = mock(UserInfo.class); + MediaData mediaData = mock(MediaData.class); + InputStream content = new ByteArrayInputStream("test".getBytes()); when(context.getAttachmentIds()).thenReturn(Map.of("ID", "test-attachment-id")); + when(context.getContentId()).thenReturn("test-content-id"); when(context.getUserInfo()).thenReturn(userInfo); + when(context.getData()).thenReturn(mediaData); + when(mediaData.getContent()).thenReturn(content); when(userInfo.getTenant()).thenReturn("test-tenant"); // Act handler.afterCreateAttachment(context); // Assert - verify(extractionService).startExtraction("test-attachment-id", "test-tenant"); + verify(extractionService).startExtraction("test-attachment-id", "test-content-id", "test-tenant", content); } @Test @@ -50,7 +58,7 @@ void afterCreateAttachment_skipsExtractionWhenAttachmentIdIsNull() { handler.afterCreateAttachment(context); // Assert - verify(extractionService, never()).startExtraction(any(), any()); + verify(extractionService, never()).startExtraction(any(), any(), any(), any()); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java deleted file mode 100644 index 972a797..0000000 --- a/sap-document-ai/src/test/java/com/sap/cds/ExtractionOrchestratorTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.sap.cds; - -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; -import com.sap.cds.orchestrator.ExtractionOrchestrator; -import com.sap.cds.ql.cqn.CqnInsert; -import com.sap.cds.ql.cqn.CqnUpdate; -import com.sap.cds.services.persistence.PersistenceService; -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; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class ExtractionOrchestratorTest { - - @Mock - PersistenceService persistenceService; - - @Mock - Result insertResult; - - ExtractionOrchestrator orchestrator; - - @BeforeEach - void setUp() { - ExtractionJob createdJob = ExtractionJob.create(); - createdJob.setId("test-job-id"); - when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); - when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); - lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); - orchestrator = new ExtractionOrchestrator(persistenceService); - } - - @Test - void startExtraction_createsOneJobWithCorrectFields() { - // Arrange - String attachmentId = "att-123"; - String tenantId = "tenant-1"; - - // Act - orchestrator.startExtraction(attachmentId, tenantId); - - // Assert - ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); - verify(persistenceService, times(1)).run(insertCaptor.capture()); - ExtractionJob inserted = Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); - assertThat(inserted.getAttachmentId()).isEqualTo(attachmentId); - assertThat(inserted.getTenantId()).isEqualTo(tenantId); - } - - @Test - void startExtraction_transitionsStatusToProcessingThenCompleted() { - // Arrange - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - - // Act - orchestrator.startExtraction("att-123", "tenant-1"); - - // Assert - await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> - verify(persistenceService, times(2)).run(updateCaptor.capture()) - ); - List updates = updateCaptor.getAllValues(); - assertThat(Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(ExtractionStatus.PROCESSING); - assertThat(Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(ExtractionStatus.COMPLETED); - } - - @Test - void startExtraction_transitionsStatusToFailedWhenProcessingThrows() { - // Arrange - when(persistenceService.run(any(CqnUpdate.class))) - .thenThrow(new RuntimeException("simulated processing failure")); - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - - // Act - orchestrator.startExtraction("att-123", "tenant-1"); - - // Assert - await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> - verify(persistenceService, atLeastOnce()).run(updateCaptor.capture()) - ); - List updates = updateCaptor.getAllValues(); - ExtractionJob lastUpdate = Struct.access(updates.get(updates.size() - 1).entries().get(0)).as(ExtractionJob.class); - assertThat(lastUpdate.getStatus()).isEqualTo(ExtractionStatus.FAILED); - } - -} diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java index 49ac12f..8981974 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java @@ -2,6 +2,7 @@ import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; +import com.sap.cds.service.DocumentAiProcessingService; import com.sap.cds.service.ExtractionServiceImpl; import com.sap.cds.ql.cqn.CqnInsert; import com.sap.cds.ql.cqn.CqnUpdate; @@ -13,6 +14,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.util.List; import java.util.concurrent.TimeUnit; @@ -23,14 +26,18 @@ @ExtendWith(MockitoExtension.class) class ExtractionServiceImplTest { - @Mock PersistenceService persistenceService; + @Mock + DocumentAiProcessingService documentAiProcessingService; + @Mock Result insertResult; - ExtractionServiceImpl orchestrator; + ExtractionServiceImpl extractionService; + + InputStream mockContent; @BeforeEach void setUp() { @@ -39,17 +46,19 @@ void setUp() { when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); - orchestrator = new ExtractionServiceImpl(persistenceService); + mockContent = new ByteArrayInputStream("test-content".getBytes()); + extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); } @Test void startExtraction_createsOneJobWithCorrectFields() { // Arrange String attachmentId = "att-123"; + String contentId = "cnt-123"; String tenantId = "tenant-1"; // Act - orchestrator.startExtraction(attachmentId, tenantId); + extractionService.startExtraction(attachmentId, contentId, tenantId, mockContent); // Assert ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); @@ -65,7 +74,7 @@ void startExtraction_transitionsStatusToProcessingThenCompleted() { ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); // Act - orchestrator.startExtraction("att-123", "tenant-1"); + extractionService.startExtraction("att-123", "cnt-123", "tenant-1", mockContent); // Assert await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> @@ -86,7 +95,7 @@ void startExtraction_transitionsStatusToFailedWhenProcessingThrows() { ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); // Act - orchestrator.startExtraction("att-123", "tenant-1"); + extractionService.startExtraction("att-123", "cnt-123", "tenant-1", mockContent); // Assert await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> From 41c2215bd9be015dc2acd6f361479bef8a726c44 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 29 May 2026 13:14:46 +0200 Subject: [PATCH 20/70] clean up --- sap-document-ai/package.json | 9 --------- sap-document-ai/pom.xml | 4 ++-- .../AttachmentEventHandlerRegistration.java | 6 ++---- .../com/sap/cds/handlers/AttachmentEventHandler.java | 1 - .../java/com/sap/cds/service/ExtractionServiceImpl.java | 1 + 5 files changed, 5 insertions(+), 16 deletions(-) delete mode 100644 sap-document-ai/package.json diff --git a/sap-document-ai/package.json b/sap-document-ai/package.json deleted file mode 100644 index 50522de..0000000 --- a/sap-document-ai/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "sap-document-ai-cds", - "version": "1.0.0", - "description": "CDS model for sap-document-ai plugin", - "license": "ISC", - "devDependencies": { - "@sap/cds-dk": "^9" - } -} diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 9b7c96c..7573bc1 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -1,5 +1,5 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> 4.0.0 com.sap.cds sap-document-ai @@ -124,4 +124,4 @@ - \ No newline at end of file + diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index 9c5ede1..19cc4c1 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -13,10 +13,8 @@ public class AttachmentEventHandlerRegistration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - ServiceCatalog serviceCatalog = configurer.getCdsRuntime() - .getServiceCatalog(); - PersistenceService persistenceService = serviceCatalog - .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + ServiceCatalog serviceCatalog = configurer.getCdsRuntime().getServiceCatalog(); + PersistenceService persistenceService = serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); DocumentAiProcessingService documentAiProcessingService = new DefaultDocumentAiProcessingService(); ExtractionService extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); configurer.eventHandler(new AttachmentEventHandler(extractionService)); diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 8721672..9848475 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -27,7 +27,6 @@ public AttachmentEventHandler(ExtractionService extractionService) { public void afterCreateAttachment(AttachmentCreateEventContext context) { String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); String tenantId = context.getUserInfo().getTenant(); - if (attachmentId == null) { log.warn("[sap-document-ai] attachmentId is null, skipping extraction"); return; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 6dd1032..605c3aa 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -62,4 +62,5 @@ private void updateStatus(String jobId, String status) { persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); } + } From 8ddf50c821361191abd1032a98a0bf88d3f11a64 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 29 May 2026 17:51:40 +0200 Subject: [PATCH 21/70] tiny improvements / comments --- .../AttachmentEventHandlerRegistration.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index 19cc4c1..d8fa2e9 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -7,16 +7,33 @@ import com.sap.cds.service.ExtractionServiceImpl; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; public class AttachmentEventHandlerRegistration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - ServiceCatalog serviceCatalog = configurer.getCdsRuntime().getServiceCatalog(); - PersistenceService persistenceService = serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - DocumentAiProcessingService documentAiProcessingService = new DefaultDocumentAiProcessingService(); - ExtractionService extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); - configurer.eventHandler(new AttachmentEventHandler(extractionService)); + CdsRuntime runtime = configurer.getCdsRuntime(); + ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); + + // framework-managed dependency + PersistenceService persistenceService = + serviceCatalog.getService( + PersistenceService.class, + PersistenceService.DEFAULT_NAME); + + // internal + DocumentAiProcessingService documentAiProcessingService = + new DefaultDocumentAiProcessingService(); + + ExtractionService extractionService = + new ExtractionServiceImpl( + persistenceService, + documentAiProcessingService); + + // register event handler with CAP runtime + configurer.eventHandler( + new AttachmentEventHandler(extractionService)); } } From af28906e5ae58f55685f19797ed09d62d503ce68 Mon Sep 17 00:00:00 2001 From: "hyperspace-insights[bot]" <209611008+hyperspace-insights[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:55:22 +0000 Subject: [PATCH 22/70] =?UTF-8?q?chore(hyperspace):=20=F0=9F=A4=96=20Add?= =?UTF-8?q?=20PR=20Bot=20Configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hyperspace/pull_request_bot.json | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .hyperspace/pull_request_bot.json diff --git a/.hyperspace/pull_request_bot.json b/.hyperspace/pull_request_bot.json new file mode 100644 index 0000000..af7508b --- /dev/null +++ b/.hyperspace/pull_request_bot.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://devops-insights-pr-bot.cfapps.eu10-004.hana.ondemand.com/schema/pull_request_bot.json", + "features": { + "control_panel": false, + "summarize": { + "auto_generate_summary": true, + "auto_insert_summary": true, + "auto_run_on_draft_pr": true, + "use_custom_summarize_prompt": false, + "use_custom_summarize_output_template": false, + "excluded_paths": [], + "auto_exclude_authors": [] + }, + "review": { + "auto_generate_review": true, + "auto_run_on_draft_pr": false, + "use_custom_review_focus": false, + "excluded_paths": [], + "auto_exclude_authors": [] + }, + "sonar_fix": { + "enable": true, + "excluded_rules": [] + }, + "pipeline_fix": { + "enable": true + } + }, + "excluded_paths": [] +} From 633786c6a79586c2d3c68ea67a7e13c342e807ce Mon Sep 17 00:00:00 2001 From: Samyuktha Prabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:59:45 +0200 Subject: [PATCH 23/70] Create main.yml move the file one level lower attempt to fix failing test attempt 2 to fix failing test attempt 3 to fix failing test attempt 4 to fix failing test attempt 5 to fix failing workflow test clean up configure pmd, jacoco and mvn spotless fix version tests --- .github/workflows/main.yml | 33 ++++ sap-document-ai/pom.xml | 125 +++++++++++- .../AttachmentEventHandlerRegistration.java | 38 ++-- .../cds/handlers/AttachmentEventHandler.java | 47 +++-- .../DefaultDocumentAiProcessingService.java | 35 ++-- .../service/DocumentAiProcessingService.java | 6 +- .../sap/cds/service/ExtractionService.java | 6 +- .../cds/service/ExtractionServiceImpl.java | 99 ++++++---- .../sap/cds/AttachmentEventHandlerTest.java | 85 ++++---- .../sap/cds/ExtractionServiceImplTest.java | 183 +++++++++--------- ...efaultDocumentAiProcessingServiceTest.java | 45 +++++ 11 files changed, 460 insertions(+), 242 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..b0ed368 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,33 @@ +name: Main + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install @sap/cds-dk + run: npm i -g @sap/cds-dk@9.9.1 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: sapmachine + cache: maven + + - name: Build / run tests + working-directory: sap-document-ai + run: mvn clean verify diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 7573bc1..355b109 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -1,10 +1,10 @@ - + + 4.0.0 com.sap.cds sap-document-ai - jar 1.0-SNAPSHOT + jar sap-document-ai @@ -101,7 +101,9 @@ - build --for java --src ${project.basedir}/src/main/resources/cds/com.sap.cds/sap-document-ai --dest ${project.basedir}/gen/srv + build --for java --src + ${project.basedir}/src/main/resources/cds/com.sap.cds/sap-document-ai --dest + ${project.basedir}/gen/srv @@ -122,6 +124,121 @@ + + maven-pmd-plugin + 3.28.0 + + ${jdk.version} + 5 + ${project.build.directory} + true + true + false + false + + + /rulesets/java/maven-pmd-plugin-default.xml + + + **/feature/documentai/generated/** + + + + + + com.sap.cloud.sdk.quality + pmd-rules + 3.78.0 + + + + + pmd-error + + check + cpd-check + + process-test-classes + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + **/feature/documentai/generated/** + + + + + jacoco-prepare + + prepare-agent + + + + jacoco-report + + report + + verify + + + jacoco-check + + check + + verify + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.75 + + + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.1.0 + + + + + + + /* +* © $YEAR SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ + + + + + pom.xml + + + + + + + + check + + process-sources + + + diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index d8fa2e9..907a036 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -1,3 +1,6 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ package com.sap.cds.configuration; import com.sap.cds.handlers.AttachmentEventHandler; @@ -12,28 +15,23 @@ import com.sap.cds.services.runtime.CdsRuntimeConfigurer; public class AttachmentEventHandlerRegistration implements CdsRuntimeConfiguration { - @Override - public void eventHandlers(CdsRuntimeConfigurer configurer) { - CdsRuntime runtime = configurer.getCdsRuntime(); - ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); + @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); + // framework-managed dependency + PersistenceService persistenceService = + serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - // internal - DocumentAiProcessingService documentAiProcessingService = - new DefaultDocumentAiProcessingService(); + // internal + DocumentAiProcessingService documentAiProcessingService = + new DefaultDocumentAiProcessingService(); - ExtractionService extractionService = - new ExtractionServiceImpl( - persistenceService, - documentAiProcessingService); + ExtractionService extractionService = + new ExtractionServiceImpl(persistenceService, documentAiProcessingService); - // register event handler with CAP runtime - configurer.eventHandler( - new AttachmentEventHandler(extractionService)); - } + // register event handler with CAP runtime + configurer.eventHandler(new AttachmentEventHandler(extractionService)); + } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 9848475..9ffc111 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -1,3 +1,6 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ package com.sap.cds.handlers; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; @@ -7,36 +10,38 @@ 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.io.InputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.InputStream; - @ServiceName(value = "*", type = AttachmentService.class) public class AttachmentEventHandler implements EventHandler { - private static final Logger log = LoggerFactory.getLogger(AttachmentEventHandler.class); + private static final Logger log = LoggerFactory.getLogger(AttachmentEventHandler.class); - private final ExtractionService extractionService; + private final ExtractionService extractionService; - public AttachmentEventHandler(ExtractionService extractionService) { - this.extractionService = extractionService; - } + public AttachmentEventHandler(ExtractionService extractionService) { + this.extractionService = extractionService; + } - @After(event = AttachmentService.EVENT_CREATE_ATTACHMENT) - public void afterCreateAttachment(AttachmentCreateEventContext context) { - String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); - String tenantId = context.getUserInfo().getTenant(); - if (attachmentId == null) { - log.warn("[sap-document-ai] attachmentId is null, skipping extraction"); - return; - } - - String contentId = context.getContentId(); - InputStream content = context.getData().getContent(); - - log.info("[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, contentId={}, tenantId={}", attachmentId, contentId, tenantId); - extractionService.startExtraction(attachmentId, contentId, tenantId, content); + @After(event = AttachmentService.EVENT_CREATE_ATTACHMENT) + public void afterCreateAttachment(AttachmentCreateEventContext context) { + String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); + String tenantId = context.getUserInfo().getTenant(); + if (attachmentId == null) { + log.warn("[sap-document-ai] attachmentId is null, skipping extraction"); + return; } + String contentId = context.getContentId(); + InputStream content = context.getData().getContent(); + + log.info( + "[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, contentId={}, tenantId={}", + attachmentId, + contentId, + tenantId); + extractionService.startExtraction(attachmentId, contentId, tenantId, content); + } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index 9ffb8cc..f2bc028 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -1,26 +1,29 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ package com.sap.cds.service; +import java.io.InputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.InputStream; - public class DefaultDocumentAiProcessingService implements DocumentAiProcessingService { - private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiProcessingService.class); + private static final Logger logger = + LoggerFactory.getLogger(DefaultDocumentAiProcessingService.class); - @Override - public void processDocument(String jobId, InputStream content) { - logger.info("[sap-document-ai] Processing document for jobId={} and content={}", jobId, content); - try { - // TODO: Replace mock delay with real Document AI integration - Thread.sleep(3000); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.error("[sap-document-ai] Interrupted during extraction for jobId={}", jobId, e); - } catch (Exception e) { - logger.error("[sap-document-ai] Extraction failed for jobId={}", jobId, e); - } + @Override + public void processDocument(String jobId, InputStream content) { + logger.info( + "[sap-document-ai] Processing document for jobId={} and content={}", jobId, content); + try { + // TODO: Replace mock delay with real Document AI integration + Thread.sleep(3000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("[sap-document-ai] Interrupted during extraction for jobId={}", jobId, e); + } catch (Exception e) { + logger.error("[sap-document-ai] Extraction failed for jobId={}", jobId, e); } + } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java index 4f28680..43410cc 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java @@ -1,9 +1,11 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ package com.sap.cds.service; import java.io.InputStream; public interface DocumentAiProcessingService { - void processDocument(String jobId, InputStream content); - + void processDocument(String jobId, InputStream content); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index b62885f..3f9ef3d 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -1,9 +1,11 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ package com.sap.cds.service; import java.io.InputStream; public interface ExtractionService { - void startExtraction(String attachmentId, String contentId, String tenantId, InputStream content); - + void startExtraction(String attachmentId, String contentId, String tenantId, InputStream content); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 605c3aa..3be635a 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -1,3 +1,6 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ package com.sap.cds.service; import com.sap.cds.Result; @@ -7,60 +10,70 @@ import com.sap.cds.ql.Insert; import com.sap.cds.ql.Update; import com.sap.cds.services.persistence.PersistenceService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.InputStream; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class ExtractionServiceImpl implements ExtractionService { - private static final Logger logger = LoggerFactory.getLogger(ExtractionServiceImpl.class); - private static final int MAX_PARALLEL_EXTRACTIONS = Runtime.getRuntime().availableProcessors(); - private static final ExecutorService executor = Executors.newFixedThreadPool(MAX_PARALLEL_EXTRACTIONS); - - private final PersistenceService persistenceService; - private final DocumentAiProcessingService documentAiProcessingService; + private static final Logger logger = LoggerFactory.getLogger(ExtractionServiceImpl.class); + private static final int MAX_PARALLEL_EXTRACTIONS = Runtime.getRuntime().availableProcessors(); + private static final ExecutorService executor = + Executors.newFixedThreadPool(MAX_PARALLEL_EXTRACTIONS); - public ExtractionServiceImpl(PersistenceService persistenceService, DocumentAiProcessingService documentAiProcessingService) { - this.persistenceService = persistenceService; - this.documentAiProcessingService = documentAiProcessingService; - } + private final PersistenceService persistenceService; + private final DocumentAiProcessingService documentAiProcessingService; - @Override - public void startExtraction(String attachmentId, String contentId, String tenantId, InputStream content) { - logger.info("[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); - String jobId = createExtractionJob(attachmentId, tenantId); + public ExtractionServiceImpl( + PersistenceService persistenceService, + DocumentAiProcessingService documentAiProcessingService) { + this.persistenceService = persistenceService; + this.documentAiProcessingService = documentAiProcessingService; + } - CompletableFuture.runAsync(() -> { - try { - updateStatus(jobId, ExtractionStatus.PROCESSING); - documentAiProcessingService.processDocument(jobId, content); - updateStatus(jobId, ExtractionStatus.COMPLETED); - } catch (Exception e) { - updateStatus(jobId, ExtractionStatus.FAILED); - } - }, executor); - } + @Override + public void startExtraction( + String attachmentId, String contentId, String tenantId, InputStream content) { + logger.info( + "[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", + attachmentId, + tenantId); + String jobId = createExtractionJob(attachmentId, tenantId); - private String createExtractionJob(String attachmentId, String tenantId) { - ExtractionJob job = ExtractionJob.create(); - job.setAttachmentId(attachmentId); - job.setTenantId(tenantId); + CompletableFuture.runAsync( + () -> { + try { + updateStatus(jobId, ExtractionStatus.PROCESSING); + documentAiProcessingService.processDocument(jobId, content); + updateStatus(jobId, ExtractionStatus.COMPLETED); + } catch (Exception e) { + updateStatus(jobId, ExtractionStatus.FAILED); + } + }, + executor); + } - 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 for attachmentId={} & jobId={}", attachmentId, jobId); - return jobId; - } + private String createExtractionJob(String attachmentId, String tenantId) { + ExtractionJob job = ExtractionJob.create(); + job.setAttachmentId(attachmentId); + job.setTenantId(tenantId); - private void updateStatus(String jobId, String status) { - ExtractionJob extractionJob = ExtractionJob.create(); - extractionJob.setStatus(status); - persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); - logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); - } + 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 for attachmentId={} & jobId={}", + attachmentId, + jobId); + return jobId; + } - } + private void updateStatus(String jobId, String status) { + ExtractionJob extractionJob = ExtractionJob.create(); + extractionJob.setStatus(status); + persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); + logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index 70914c0..36ffe61 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -1,64 +1,65 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ package com.sap.cds; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.handlers.AttachmentEventHandler; import com.sap.cds.service.ExtractionService; import com.sap.cds.services.request.UserInfo; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.util.Map; - -import static org.mockito.Mockito.*; -import static org.mockito.ArgumentMatchers.any; - @ExtendWith(MockitoExtension.class) class AttachmentEventHandlerTest { - @Mock - ExtractionService extractionService; - - @Test - void afterCreateAttachment_triggersOrchestrationWithTenant() { - // Arrange - AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); - AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); - UserInfo userInfo = mock(UserInfo.class); - MediaData mediaData = mock(MediaData.class); - InputStream content = new ByteArrayInputStream("test".getBytes()); - when(context.getAttachmentIds()).thenReturn(Map.of("ID", "test-attachment-id")); - when(context.getContentId()).thenReturn("test-content-id"); - when(context.getUserInfo()).thenReturn(userInfo); - when(context.getData()).thenReturn(mediaData); - when(mediaData.getContent()).thenReturn(content); - when(userInfo.getTenant()).thenReturn("test-tenant"); + @Mock ExtractionService extractionService; - // Act - handler.afterCreateAttachment(context); + @Test + void afterCreateAttachmentTriggersOrchestrationWithTenant() { + // Arrange + AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); + AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); + UserInfo userInfo = mock(UserInfo.class); + MediaData mediaData = mock(MediaData.class); + InputStream content = new ByteArrayInputStream("test".getBytes()); + when(context.getAttachmentIds()).thenReturn(Map.of("ID", "test-attachment-id")); + when(context.getContentId()).thenReturn("test-content-id"); + when(context.getUserInfo()).thenReturn(userInfo); + when(context.getData()).thenReturn(mediaData); + when(mediaData.getContent()).thenReturn(content); + when(userInfo.getTenant()).thenReturn("test-tenant"); - // Assert - verify(extractionService).startExtraction("test-attachment-id", "test-content-id", "test-tenant", content); - } + // Act + handler.afterCreateAttachment(context); - @Test - void afterCreateAttachment_skipsExtractionWhenAttachmentIdIsNull() { - // Arrange - AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); - AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); - UserInfo userInfo = mock(UserInfo.class); - when(context.getAttachmentIds()).thenReturn(Map.of()); - when(context.getUserInfo()).thenReturn(userInfo); + // Assert + verify(extractionService) + .startExtraction("test-attachment-id", "test-content-id", "test-tenant", content); + } - // Act - handler.afterCreateAttachment(context); + @Test + void afterCreateAttachmentSkipsExtractionWhenAttachmentIdIsNull() { + // Arrange + AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); + AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); + UserInfo userInfo = mock(UserInfo.class); + when(context.getAttachmentIds()).thenReturn(Map.of()); + when(context.getUserInfo()).thenReturn(userInfo); - // Assert - verify(extractionService, never()).startExtraction(any(), any(), any(), any()); - } + // Act + handler.afterCreateAttachment(context); + // Assert + verify(extractionService, never()).startExtraction(any(), any(), any(), any()); + } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java index 8981974..76a5d11 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java @@ -1,12 +1,24 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ package com.sap.cds; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; -import com.sap.cds.service.DocumentAiProcessingService; -import com.sap.cds.service.ExtractionServiceImpl; import com.sap.cds.ql.cqn.CqnInsert; import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.service.DocumentAiProcessingService; +import com.sap.cds.service.ExtractionServiceImpl; import com.sap.cds.services.persistence.PersistenceService; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.List; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,96 +26,83 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) class ExtractionServiceImplTest { - @Mock - PersistenceService persistenceService; - - @Mock - DocumentAiProcessingService documentAiProcessingService; - - @Mock - Result insertResult; - - ExtractionServiceImpl extractionService; - - InputStream mockContent; - - @BeforeEach - void setUp() { - ExtractionJob createdJob = ExtractionJob.create(); - createdJob.setId("test-job-id"); - when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); - when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); - lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); - mockContent = new ByteArrayInputStream("test-content".getBytes()); - extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); - } - - @Test - void startExtraction_createsOneJobWithCorrectFields() { - // Arrange - String attachmentId = "att-123"; - String contentId = "cnt-123"; - String tenantId = "tenant-1"; - - // Act - extractionService.startExtraction(attachmentId, contentId, tenantId, mockContent); - - // Assert - ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); - verify(persistenceService, times(1)).run(insertCaptor.capture()); - ExtractionJob inserted = Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); - assertThat(inserted.getAttachmentId()).isEqualTo(attachmentId); - assertThat(inserted.getTenantId()).isEqualTo(tenantId); - } - - @Test - void startExtraction_transitionsStatusToProcessingThenCompleted() { - // Arrange - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - - // Act - extractionService.startExtraction("att-123", "cnt-123", "tenant-1", mockContent); - - // Assert - await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> - verify(persistenceService, times(2)).run(updateCaptor.capture()) - ); - List updates = updateCaptor.getAllValues(); - assertThat(Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(ExtractionStatus.PROCESSING); - assertThat(Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(ExtractionStatus.COMPLETED); - } - - @Test - void startExtraction_transitionsStatusToFailedWhenProcessingThrows() { - // Arrange - when(persistenceService.run(any(CqnUpdate.class))) - .thenThrow(new RuntimeException("simulated processing failure")); - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - - // Act - extractionService.startExtraction("att-123", "cnt-123", "tenant-1", mockContent); - - // Assert - await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> - verify(persistenceService, atLeastOnce()).run(updateCaptor.capture()) - ); - List updates = updateCaptor.getAllValues(); - ExtractionJob lastUpdate = Struct.access(updates.get(updates.size() - 1).entries().get(0)).as(ExtractionJob.class); - assertThat(lastUpdate.getStatus()).isEqualTo(ExtractionStatus.FAILED); - } - + public static final String TENANT_1 = "tenant-1"; + public static final String ATT_123 = "att-123"; + public static final String CNT_123 = "cnt-123"; + @Mock PersistenceService persistenceService; + + @Mock DocumentAiProcessingService documentAiProcessingService; + + @Mock Result insertResult; + + ExtractionServiceImpl extractionService; + + InputStream mockContent; + + @BeforeEach + void setUp() { + ExtractionJob createdJob = ExtractionJob.create(); + createdJob.setId("test-job-id"); + when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); + when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); + lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + mockContent = new ByteArrayInputStream("test-content".getBytes()); + extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); + } + + @Test + void startExtractionCreatesOneJobWithCorrectFields() { + // Arrange + String attachmentId = ATT_123; + String contentId = CNT_123; + String tenantId = TENANT_1; + + // Act + extractionService.startExtraction(attachmentId, contentId, tenantId, mockContent); + + // Assert + ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); + verify(persistenceService, times(1)).run(insertCaptor.capture()); + ExtractionJob inserted = + Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); + assertThat(inserted.getAttachmentId()).isEqualTo(attachmentId); + assertThat(inserted.getTenantId()).isEqualTo(tenantId); + } + + @Test + void startExtractionTransitionsStatusToProcessingThenCompleted() { + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + List updates = awaitTwoStatusUpdates(); + assertStatusSequence(updates, ExtractionStatus.PROCESSING, ExtractionStatus.COMPLETED); + } + + @Test + void startExtractionTransitionsStatusToFailedWhenProcessingThrows() { + doThrow(new RuntimeException("simulated processing failure")) + .when(documentAiProcessingService) + .processDocument(any(), any()); + + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + List updates = awaitTwoStatusUpdates(); + assertStatusSequence(updates, ExtractionStatus.PROCESSING, ExtractionStatus.FAILED); + } + + private List awaitTwoStatusUpdates() { + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> verify(persistenceService, times(2)).run(updateCaptor.capture())); + return updateCaptor.getAllValues(); + } + + private void assertStatusSequence(List updates, String first, String second) { + assertThat(Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) + .isEqualTo(first); + assertThat(Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) + .isEqualTo(second); + } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java new file mode 100644 index 0000000..552268a --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -0,0 +1,45 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +*/ +package com.sap.cds.service; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/* + * Temporary tests until the real implementation is done + * */ +class DefaultDocumentAiProcessingServiceTest { + + public static final String TEST = "test"; + DefaultDocumentAiProcessingService service; + + @BeforeEach + void setUp() { + service = new DefaultDocumentAiProcessingService(); + } + + @Test + void processDocumentCompletesWithoutException() { + InputStream content = new ByteArrayInputStream(TEST.getBytes()); + assertThatCode(() -> service.processDocument("job-1", content)).doesNotThrowAnyException(); + } + + @Test + void processDocumentHandlesInterruption() throws InterruptedException { + InputStream content = new ByteArrayInputStream(TEST.getBytes()); + Thread thread = + new Thread( + () -> { + assertThatCode(() -> service.processDocument("job-2", content)) + .doesNotThrowAnyException(); + }); + thread.start(); + thread.interrupt(); + thread.join(); + } +} From bf05af0c74da649a792c1be86bca74a78c1e06db Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:11:57 +0200 Subject: [PATCH 24/70] initial commit --- .../java/com/sap/cds/handlers/AttachmentEventHandler.java | 2 +- .../main/java/com/sap/cds/service/ExtractionService.java | 2 +- .../java/com/sap/cds/service/ExtractionServiceImpl.java | 7 ++++++- .../test/java/com/sap/cds/AttachmentEventHandlerTest.java | 2 +- .../test/java/com/sap/cds/ExtractionServiceImplTest.java | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 9ffc111..330c8aa 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -1,5 +1,5 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds.handlers; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index 3f9ef3d..d15f7ec 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -1,5 +1,5 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds.service; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 3be635a..e41d142 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -1,5 +1,5 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds.service; @@ -50,6 +50,11 @@ public void startExtraction( documentAiProcessingService.processDocument(jobId, content); updateStatus(jobId, ExtractionStatus.COMPLETED); } catch (Exception e) { + logger.info( + "[sap-document-ai] Something went wrong while triggering orchestration - for attachmentId={}, tenantId={}, error={}", + attachmentId, + tenantId, + e); updateStatus(jobId, ExtractionStatus.FAILED); } }, diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index 36ffe61..3b81ac6 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -1,5 +1,5 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds; diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java index 76a5d11..30e1d1a 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java @@ -1,5 +1,5 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds; From b3bd2b0ff963004703f9705d083ef87056d83435 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:39:14 +0200 Subject: [PATCH 25/70] credential handling using service binding and running using local hybrid mode --- .../AttachmentEventHandlerRegistration.java | 68 ++++++++++++++++++- .../DefaultDocumentAiProcessingService.java | 12 +++- .../service/DocumentAiProcessingService.java | 2 +- .../client/DefaultDocumentAiClient.java | 34 ++++++++++ .../documentai/client/DocumentAiClient.java | 10 +++ 5 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index 907a036..a264161 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -1,5 +1,5 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds.configuration; @@ -8,13 +8,36 @@ import com.sap.cds.service.DocumentAiProcessingService; import com.sap.cds.service.ExtractionService; import com.sap.cds.service.ExtractionServiceImpl; +import com.sap.cds.service.documentai.client.DefaultDocumentAiClient; +import com.sap.cds.service.documentai.client.DocumentAiClient; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.*; +import java.util.Optional; +import org.apache.http.client.HttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class AttachmentEventHandlerRegistration implements CdsRuntimeConfiguration { + + private static final Logger logger = + LoggerFactory.getLogger(AttachmentEventHandlerRegistration.class); + + static { + OAuth2ServiceBindingDestinationLoader.registerPropertySupplier( + options -> + ServiceBindingUtils.matches( + options.getServiceBinding(), + DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL), + DefaultOAuth2PropertySupplier::new); + } + @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); @@ -25,8 +48,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); // internal + DocumentAiClient documentAiClient = buildDocumentAi(runtime.getEnvironment()); DocumentAiProcessingService documentAiProcessingService = - new DefaultDocumentAiProcessingService(); + new DefaultDocumentAiProcessingService(documentAiClient); ExtractionService extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); @@ -34,4 +58,44 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { // register event handler with CAP runtime configurer.eventHandler(new AttachmentEventHandler(extractionService)); } + + 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 = HttpClientAccessor.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/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index f2bc028..af66bb9 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -1,8 +1,9 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds.service; +import com.sap.cds.service.documentai.client.DocumentAiClient; import java.io.InputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,6 +12,13 @@ public class DefaultDocumentAiProcessingService implements DocumentAiProcessingS private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiProcessingService.class); + 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 void processDocument(String jobId, InputStream content) { @@ -19,6 +27,8 @@ public void processDocument(String jobId, InputStream content) { try { // TODO: Replace mock delay with real Document AI integration Thread.sleep(3000); + String result = documentAiClient.submitDocument(content); + logger.info("logging the result after submitting document {}", result); } catch (InterruptedException e) { Thread.currentThread().interrupt(); logger.error("[sap-document-ai] Interrupted during extraction for jobId={}", jobId, e); diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java index 43410cc..0cf1c16 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java @@ -1,5 +1,5 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds.service; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java new file mode 100644 index 0000000..2991751 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java @@ -0,0 +1,34 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.documentai.client; + +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.io.InputStream; +import java.net.URI; +import org.apache.http.client.HttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DefaultDocumentAiClient implements DocumentAiClient { + + private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiClient.class); + + private final HttpDestination destination; + + // TODO: Remove this suppress warning once httpClient is used in submitDocument + @SuppressWarnings("PMD.UnusedPrivateField") + private final HttpClient httpClient; + + public DefaultDocumentAiClient(HttpDestination destination, HttpClient httpClient) { + this.destination = destination; + this.httpClient = httpClient; + } + + @Override + public String submitDocument(InputStream content) { + URI baseUri = destination.getUri(); + logger.info("[sap-document-ai] Submitting document to DIE at url={}", baseUri); + return null; + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java new file mode 100644 index 0000000..ed61919 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java @@ -0,0 +1,10 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.documentai.client; + +import java.io.InputStream; + +public interface DocumentAiClient { + String submitDocument(InputStream content); +} From b82ba7ee33ce9aa4ebf67b074a3fdfdd82db3ad6 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:39:39 +0200 Subject: [PATCH 26/70] update pom.xml --- sap-document-ai/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 355b109..761ae77 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -219,7 +219,7 @@ /* -* © $YEAR SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © $YEAR SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ From 98939292791a6a5172e4c34aba130af21a6efc63 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:42:06 +0200 Subject: [PATCH 27/70] tests --- ...ttachmentEventHandlerRegistrationTest.java | 113 ++++++++++++++++++ ...efaultDocumentAiProcessingServiceTest.java | 13 +- 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java diff --git a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java b/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java new file mode 100644 index 0000000..7d1479a --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java @@ -0,0 +1,113 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.sap.cds.service.DefaultDocumentAiProcessingService; +import com.sap.cds.service.documentai.client.DocumentAiClient; +import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpClientAccessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader; +import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.apache.http.client.HttpClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RegistrationTest { + + @Mock CdsEnvironment environment; + + @Mock ServiceBinding serviceBinding; + + @Mock HttpDestination httpDestination; + + @Mock ServiceBindingDestinationLoader destinationLoader; + + @Mock HttpClient httpClient; + + @Test + void buildDocumentAi_noBindingFound_returnsNull() { + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); + assertThat(result).isNull(); + } + + @Test + void buildDocumentAi_bindingFound_destinationCreated_returnsClient() { + // Arrange + when(environment.getServiceBindings()).thenReturn(Stream.of(serviceBinding)); + + withStaticMocks( + mocks -> { + mocks + .destinationLoader + .when(ServiceBindingDestinationLoader::defaultLoaderChain) + .thenReturn(destinationLoader); + when(destinationLoader.getDestination(any(ServiceBindingDestinationOptions.class))) + .thenReturn(httpDestination); + mocks + .clientAccessor + .when(() -> HttpClientAccessor.getHttpClient(httpDestination)) + .thenReturn(httpClient); + // Act + DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); + // Assert + assertThat(result).isNotNull(); + }); + } + + @Test + void buildDocumentAi_bindingFound_destinationCreationFails_returnsNull() { + // Arrange + when(environment.getServiceBindings()).thenReturn(Stream.of(serviceBinding)); + + withStaticMocks( + mocks -> { + mocks + .destinationLoader + .when(ServiceBindingDestinationLoader::defaultLoaderChain) + .thenReturn(destinationLoader); + when(destinationLoader.getDestination(any(ServiceBindingDestinationOptions.class))) + .thenThrow(new RuntimeException("failed to create destination")); + // Act + DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); + // Assert + assertThat(result).isNull(); + }); + } + + private void withStaticMocks(Consumer test) { + try (MockedStatic utilsMockedStatic = + mockStatic(ServiceBindingUtils.class); + MockedStatic destinationLoaderMockedStatic = + mockStatic(ServiceBindingDestinationLoader.class); + MockedStatic clientAccessorMockedStatic = + mockStatic(HttpClientAccessor.class)) { + utilsMockedStatic + .when( + () -> + ServiceBindingUtils.matches( + serviceBinding, + DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL)) + .thenReturn(true); + test.accept(new StaticMocks(destinationLoaderMockedStatic, clientAccessorMockedStatic)); + } + } + + private record StaticMocks( + MockedStatic destinationLoader, + MockedStatic clientAccessor) {} +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java index 552268a..28c2dab 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -1,10 +1,14 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ package com.sap.cds.service; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.sap.cds.service.documentai.client.DocumentAiClient; import java.io.ByteArrayInputStream; import java.io.InputStream; import org.junit.jupiter.api.BeforeEach; @@ -16,11 +20,14 @@ class DefaultDocumentAiProcessingServiceTest { public static final String TEST = "test"; + DocumentAiClient documentAiClient; DefaultDocumentAiProcessingService service; @BeforeEach - void setUp() { - service = new DefaultDocumentAiProcessingService(); + void setUp() throws Exception { + documentAiClient = mock(DocumentAiClient.class); + when(documentAiClient.submitDocument(any())).thenReturn("mock-result"); + service = new DefaultDocumentAiProcessingService(documentAiClient); } @Test From f8d1292cc4bc9d077c9d93f3ebc000f51a46f751 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:05:35 +0200 Subject: [PATCH 28/70] bot review fixes --- .../configuration/AttachmentEventHandlerRegistration.java | 3 ++- .../cds/service/DefaultDocumentAiProcessingService.java | 8 ++++++-- .../java/com/sap/cds/service/ExtractionServiceImpl.java | 2 +- .../AttachmentEventHandlerRegistrationTest.java | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index a264161..d1d2c87 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -18,8 +18,9 @@ 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.util.Optional; + +import com.sap.cloud.sdk.cloudplatform.connectivity.*; import org.apache.http.client.HttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index af66bb9..cecf588 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -25,10 +25,14 @@ public void processDocument(String jobId, InputStream content) { logger.info( "[sap-document-ai] Processing document for jobId={} and content={}", jobId, content); try { - // TODO: Replace mock delay with real Document AI integration + if (documentAiClient == null) { + logger.warn("[sap-document-ai] Document AI client is not available, skipping submission for jobId={}", jobId); + return; + } Thread.sleep(3000); + // TODO: Replace mock delay with real Document AI integration String result = documentAiClient.submitDocument(content); - logger.info("logging the result after submitting document {}", result); + logger.info("[sap-document-ai] Document submitted successfully for jobId={}, result={}", jobId, result); } catch (InterruptedException e) { Thread.currentThread().interrupt(); logger.error("[sap-document-ai] Interrupted during extraction for jobId={}", jobId, e); diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index e41d142..f372b2d 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -50,7 +50,7 @@ public void startExtraction( documentAiProcessingService.processDocument(jobId, content); updateStatus(jobId, ExtractionStatus.COMPLETED); } catch (Exception e) { - logger.info( + logger.error( "[sap-document-ai] Something went wrong while triggering orchestration - for attachmentId={}, tenantId={}, error={}", attachmentId, tenantId, diff --git a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java b/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java index 7d1479a..e8c54af 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java @@ -26,7 +26,7 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) -class RegistrationTest { +class AttachmentEventHandlerRegistrationTest { @Mock CdsEnvironment environment; From f2e8779f3b4c8c68d4edcdb64bc777283e3ed271 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:08:33 +0200 Subject: [PATCH 29/70] apply spotless --- .../AttachmentEventHandlerRegistration.java | 3 +-- .../cds/service/DefaultDocumentAiProcessingService.java | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index d1d2c87..a264161 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -18,9 +18,8 @@ import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cds.services.utils.environment.ServiceBindingUtils; import com.sap.cloud.environment.servicebinding.api.ServiceBinding; -import java.util.Optional; - import com.sap.cloud.sdk.cloudplatform.connectivity.*; +import java.util.Optional; import org.apache.http.client.HttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index cecf588..610a0f4 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -26,13 +26,18 @@ public void processDocument(String jobId, InputStream content) { "[sap-document-ai] Processing document for jobId={} and content={}", jobId, content); try { if (documentAiClient == null) { - logger.warn("[sap-document-ai] Document AI client is not available, skipping submission for jobId={}", jobId); + logger.warn( + "[sap-document-ai] Document AI client is not available, skipping submission for jobId={}", + jobId); return; } Thread.sleep(3000); // TODO: Replace mock delay with real Document AI integration String result = documentAiClient.submitDocument(content); - logger.info("[sap-document-ai] Document submitted successfully for jobId={}, result={}", jobId, result); + logger.info( + "[sap-document-ai] Document submitted successfully for jobId={}, result={}", + jobId, + result); } catch (InterruptedException e) { Thread.currentThread().interrupt(); logger.error("[sap-document-ai] Interrupted during extraction for jobId={}", jobId, e); From 8092bfe483878189be704564def0b3bc7baab28d Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:58:55 +0200 Subject: [PATCH 30/70] skip extraction early when Document AI service is unavailable --- .../service/DefaultDocumentAiProcessingService.java | 11 +++++------ .../sap/cds/service/DocumentAiProcessingService.java | 2 ++ .../com/sap/cds/service/ExtractionServiceImpl.java | 6 ++++++ .../java/com/sap/cds/ExtractionServiceImplTest.java | 1 + 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index 610a0f4..7a72e23 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -25,12 +25,6 @@ public void processDocument(String jobId, InputStream content) { logger.info( "[sap-document-ai] Processing document for jobId={} and content={}", jobId, content); try { - if (documentAiClient == null) { - logger.warn( - "[sap-document-ai] Document AI client is not available, skipping submission for jobId={}", - jobId); - return; - } Thread.sleep(3000); // TODO: Replace mock delay with real Document AI integration String result = documentAiClient.submitDocument(content); @@ -45,4 +39,9 @@ public void processDocument(String jobId, InputStream content) { logger.error("[sap-document-ai] Extraction failed for jobId={}", jobId, e); } } + + @Override + public boolean isAvailable() { + return documentAiClient != null; + } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java index 0cf1c16..b212b35 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java @@ -7,5 +7,7 @@ public interface DocumentAiProcessingService { + boolean isAvailable(); + void processDocument(String jobId, InputStream content); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index f372b2d..e9ce607 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -41,6 +41,12 @@ public void startExtraction( "[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); + + if (!documentAiProcessingService.isAvailable()) { + logger.warn("[sap-document-ai] Document AI client is not available, skipping submission"); + return; + } + String jobId = createExtractionJob(attachmentId, tenantId); CompletableFuture.runAsync( diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java index 30e1d1a..d5d3c0d 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java @@ -48,6 +48,7 @@ void setUp() { when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + lenient().when(documentAiProcessingService.isAvailable()).thenReturn(true); mockContent = new ByteArrayInputStream("test-content".getBytes()); extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); } From 884ac94b5ec08478d8521d0eea8363c1f425d672 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:26:56 +0200 Subject: [PATCH 31/70] custom flows :) --- sap-document-ai/pom.xml | 3 +- .../cds/service/ExtractionServiceImpl.java | 64 +++++--- .../sap/cds/ExtractionServiceImplTest.java | 109 ------------- ...efaultDocumentAiProcessingServiceTest.java | 21 ++- .../service/ExtractionServiceImplTest.java | 154 ++++++++++++++++++ 5 files changed, 213 insertions(+), 138 deletions(-) delete mode 100644 sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 761ae77..8ac64e2 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -12,6 +12,7 @@ 4.6.0 UTF-8 com.sap.cds.feature.documentai.generated + ${project.build.directory}/site/jacoco/jacoco.xml @@ -199,7 +200,7 @@ INSTRUCTION COVEREDRATIO - 0.75 + 0.85 diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index e9ce607..63be6d0 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -8,21 +8,16 @@ import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; import com.sap.cds.services.persistence.PersistenceService; import java.io.InputStream; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ExtractionServiceImpl implements ExtractionService { private static final Logger logger = LoggerFactory.getLogger(ExtractionServiceImpl.class); - private static final int MAX_PARALLEL_EXTRACTIONS = Runtime.getRuntime().availableProcessors(); - private static final ExecutorService executor = - Executors.newFixedThreadPool(MAX_PARALLEL_EXTRACTIONS); private final PersistenceService persistenceService; private final DocumentAiProcessingService documentAiProcessingService; @@ -49,22 +44,19 @@ public void startExtraction( String jobId = createExtractionJob(attachmentId, tenantId); - CompletableFuture.runAsync( - () -> { - try { - updateStatus(jobId, ExtractionStatus.PROCESSING); - documentAiProcessingService.processDocument(jobId, content); - updateStatus(jobId, ExtractionStatus.COMPLETED); - } catch (Exception e) { - logger.error( - "[sap-document-ai] Something went wrong while triggering orchestration - for attachmentId={}, tenantId={}, error={}", - attachmentId, - tenantId, - e); - updateStatus(jobId, ExtractionStatus.FAILED); - } - }, - executor); + try { + // process document + updateStatus(jobId, ExtractionStatus.PROCESSING); + documentAiProcessingService.processDocument(jobId, content); + updateStatus(jobId, ExtractionStatus.COMPLETED); + } catch (Exception e) { + logger.error( + "[sap-document-ai] Something went wrong while triggering orchestration - for attachmentId={}, tenantId={}, error={}", + attachmentId, + tenantId, + e); + updateStatus(jobId, ExtractionStatus.FAILED); + } } private String createExtractionJob(String attachmentId, String tenantId) { @@ -81,10 +73,28 @@ private String createExtractionJob(String attachmentId, String tenantId) { return jobId; } - private void updateStatus(String jobId, String status) { - ExtractionJob extractionJob = ExtractionJob.create(); - extractionJob.setStatus(status); - persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); - logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); + void updateStatus(String jobId, String status) { + // get current status + Result current = persistenceService.run(Select.from(ExtractionJob_.class).byId(jobId)); + String currentStatus = current.single(ExtractionJob.class).getStatus(); + + // validate status + boolean isStatusUpdateValid = + (currentStatus.equals(ExtractionStatus.PENDING) + && status.equals(ExtractionStatus.PROCESSING)) + || (currentStatus.equals(ExtractionStatus.PROCESSING) + && (status.equals(ExtractionStatus.COMPLETED) + || status.equals(ExtractionStatus.FAILED))); + if (isStatusUpdateValid) { + // update to new status + ExtractionJob extractionJob = ExtractionJob.create(); + extractionJob.setStatus(status); + persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); + logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); + } else { + // reject + throw new IllegalStateException( + "Invalid status transition: " + currentStatus + " -> " + status); + } } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java deleted file mode 100644 index d5d3c0d..0000000 --- a/sap-document-ai/src/test/java/com/sap/cds/ExtractionServiceImplTest.java +++ /dev/null @@ -1,109 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; -import com.sap.cds.ql.cqn.CqnInsert; -import com.sap.cds.ql.cqn.CqnUpdate; -import com.sap.cds.service.DocumentAiProcessingService; -import com.sap.cds.service.ExtractionServiceImpl; -import com.sap.cds.services.persistence.PersistenceService; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.util.List; -import java.util.concurrent.TimeUnit; -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 ExtractionServiceImplTest { - public static final String TENANT_1 = "tenant-1"; - public static final String ATT_123 = "att-123"; - public static final String CNT_123 = "cnt-123"; - @Mock PersistenceService persistenceService; - - @Mock DocumentAiProcessingService documentAiProcessingService; - - @Mock Result insertResult; - - ExtractionServiceImpl extractionService; - - InputStream mockContent; - - @BeforeEach - void setUp() { - ExtractionJob createdJob = ExtractionJob.create(); - createdJob.setId("test-job-id"); - when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); - when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); - lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); - lenient().when(documentAiProcessingService.isAvailable()).thenReturn(true); - mockContent = new ByteArrayInputStream("test-content".getBytes()); - extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); - } - - @Test - void startExtractionCreatesOneJobWithCorrectFields() { - // Arrange - String attachmentId = ATT_123; - String contentId = CNT_123; - String tenantId = TENANT_1; - - // Act - extractionService.startExtraction(attachmentId, contentId, tenantId, mockContent); - - // Assert - ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); - verify(persistenceService, times(1)).run(insertCaptor.capture()); - ExtractionJob inserted = - Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); - assertThat(inserted.getAttachmentId()).isEqualTo(attachmentId); - assertThat(inserted.getTenantId()).isEqualTo(tenantId); - } - - @Test - void startExtractionTransitionsStatusToProcessingThenCompleted() { - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); - - List updates = awaitTwoStatusUpdates(); - assertStatusSequence(updates, ExtractionStatus.PROCESSING, ExtractionStatus.COMPLETED); - } - - @Test - void startExtractionTransitionsStatusToFailedWhenProcessingThrows() { - doThrow(new RuntimeException("simulated processing failure")) - .when(documentAiProcessingService) - .processDocument(any(), any()); - - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); - - List updates = awaitTwoStatusUpdates(); - assertStatusSequence(updates, ExtractionStatus.PROCESSING, ExtractionStatus.FAILED); - } - - private List awaitTwoStatusUpdates() { - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); - await() - .atMost(10, TimeUnit.SECONDS) - .untilAsserted(() -> verify(persistenceService, times(2)).run(updateCaptor.capture())); - return updateCaptor.getAllValues(); - } - - private void assertStatusSequence(List updates, String first, String second) { - assertThat(Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(first); - assertThat(Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(second); - } -} diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java index 28c2dab..d7afbd9 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -3,6 +3,7 @@ */ package com.sap.cds.service; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -17,6 +18,7 @@ /* * Temporary tests until the real implementation is done * */ +@SuppressWarnings("PMD.TooManyStaticImports") class DefaultDocumentAiProcessingServiceTest { public static final String TEST = "test"; @@ -24,18 +26,35 @@ class DefaultDocumentAiProcessingServiceTest { DefaultDocumentAiProcessingService service; @BeforeEach - void setUp() throws Exception { + void setUp() { documentAiClient = mock(DocumentAiClient.class); when(documentAiClient.submitDocument(any())).thenReturn("mock-result"); service = new DefaultDocumentAiProcessingService(documentAiClient); } + @Test + void isAvailableReturnsTrueWhenClientPresent() { + assertThat(service.isAvailable()).isTrue(); + } + + @Test + void isAvailableReturnsFalseWhenClientNull() { + assertThat(new DefaultDocumentAiProcessingService(null).isAvailable()).isFalse(); + } + @Test void processDocumentCompletesWithoutException() { InputStream content = new ByteArrayInputStream(TEST.getBytes()); assertThatCode(() -> service.processDocument("job-1", content)).doesNotThrowAnyException(); } + @Test + void processDocumentHandlesSubmitDocumentException() { + when(documentAiClient.submitDocument(any())).thenThrow(new RuntimeException("submit failed")); + InputStream content = new ByteArrayInputStream(TEST.getBytes()); + assertThatCode(() -> service.processDocument("job-1", content)).doesNotThrowAnyException(); + } + @Test void processDocumentHandlesInterruption() throws InterruptedException { InputStream content = new ByteArrayInputStream(TEST.getBytes()); diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java new file mode 100644 index 0000000..1de7064 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -0,0 +1,154 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service; + +import static org.mockito.ArgumentCaptor.forClass; +import static org.mockito.Mockito.*; + +import com.sap.cds.Result; +import com.sap.cds.Struct; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; +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.persistence.PersistenceService; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.List; +import org.assertj.core.api.Assertions; +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 ExtractionServiceImplTest { + + static final String TENANT_1 = "tenant-1"; + static final String ATT_123 = "att-123"; + static final String CNT_123 = "cnt-123"; + + @Mock PersistenceService persistenceService; + @Mock DocumentAiProcessingService documentAiProcessingService; + @Mock Result insertResult; + + ExtractionServiceImpl extractionService; + InputStream mockContent; + + @BeforeEach + void setUp() { + 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); + lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + lenient().when(documentAiProcessingService.isAvailable()).thenReturn(true); + Result pendingResult = jobWithStatus(ExtractionStatus.PENDING); + Result processingResult = jobWithStatus(ExtractionStatus.PROCESSING); + lenient() + .when(persistenceService.run(any(CqnSelect.class))) + .thenReturn(pendingResult, processingResult); + mockContent = new ByteArrayInputStream("test-content".getBytes()); + extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); + } + + @Test + void startExtractionDoesNothingWhenServiceUnavailable() { + when(documentAiProcessingService.isAvailable()).thenReturn(false); + + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + verify(persistenceService, never()).run(any(CqnInsert.class)); + } + + @Test + void startExtractionCreatesOneJobWithCorrectFields() { + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + ArgumentCaptor insertCaptor = forClass(CqnInsert.class); + verify(persistenceService, times(1)).run(insertCaptor.capture()); + ExtractionJob inserted = + Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); + Assertions.assertThat(inserted.getAttachmentId()).isEqualTo(ATT_123); + Assertions.assertThat(inserted.getTenantId()).isEqualTo(TENANT_1); + } + + @Test + void startExtractionTransitionsStatusToProcessingThenCompleted() { + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + List updates = captureStatusUpdates(2); + assertStatusSequence(updates, ExtractionStatus.PROCESSING, ExtractionStatus.COMPLETED); + } + + @Test + void startExtractionTransitionsStatusToFailedWhenProcessingThrows() { + doThrow(new RuntimeException("simulated failure")) + .when(documentAiProcessingService) + .processDocument(any(), any()); + + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + List updates = captureStatusUpdates(2); + assertStatusSequence(updates, ExtractionStatus.PROCESSING, ExtractionStatus.FAILED); + } + + @Test + void pendingToCompletedTransitionIsRejected() { + Result pending = jobWithStatus(ExtractionStatus.PENDING); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(pending); + + Assertions.assertThatThrownBy( + () -> extractionService.updateStatus("job-1", ExtractionStatus.COMPLETED)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Invalid status transition"); + } + + @Test + void pendingToFailedTransitionIsRejected() { + Result pending = jobWithStatus(ExtractionStatus.PENDING); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(pending); + + Assertions.assertThatThrownBy( + () -> extractionService.updateStatus("job-1", ExtractionStatus.FAILED)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Invalid status transition"); + } + + @Test + void processingToPendingTransitionIsRejected() { + Result processing = jobWithStatus(ExtractionStatus.PROCESSING); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(processing); + + Assertions.assertThatThrownBy( + () -> extractionService.updateStatus("job-1", ExtractionStatus.PENDING)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Invalid status transition"); + } + + private Result jobWithStatus(String status) { + ExtractionJob job = ExtractionJob.create(); + job.setStatus(status); + Result result = mock(Result.class); + lenient().when(result.single(ExtractionJob.class)).thenReturn(job); + return result; + } + + private List captureStatusUpdates(int expectedCount) { + ArgumentCaptor captor = forClass(CqnUpdate.class); + verify(persistenceService, times(expectedCount)).run(captor.capture()); + return captor.getAllValues(); + } + + private void assertStatusSequence(List updates, String first, String second) { + Assertions.assertThat( + Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) + .isEqualTo(first); + Assertions.assertThat( + Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) + .isEqualTo(second); + } +} From 387d22c7b476f5fa2b58ab6ba9b553fcd8e6c8e9 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:35:59 +0200 Subject: [PATCH 32/70] remove sonar code --- sap-document-ai/pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 8ac64e2..e628af3 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -12,7 +12,6 @@ 4.6.0 UTF-8 com.sap.cds.feature.documentai.generated - ${project.build.directory}/site/jacoco/jacoco.xml From 522bb6aac6dca63ced45051d0ed781bb86b0f8c3 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:43:37 +0200 Subject: [PATCH 33/70] remove threads --- .../DefaultDocumentAiProcessingService.java | 5 --- ...efaultDocumentAiProcessingServiceTest.java | 45 +++++++------------ 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index 7a72e23..33b04df 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -25,16 +25,11 @@ public void processDocument(String jobId, InputStream content) { logger.info( "[sap-document-ai] Processing document for jobId={} and content={}", jobId, content); try { - Thread.sleep(3000); - // TODO: Replace mock delay with real Document AI integration String result = documentAiClient.submitDocument(content); logger.info( "[sap-document-ai] Document submitted successfully for jobId={}, result={}", jobId, result); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.error("[sap-document-ai] Interrupted during extraction for jobId={}", jobId, e); } catch (Exception e) { logger.error("[sap-document-ai] Extraction failed for jobId={}", jobId, e); } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java index d7afbd9..cc61112 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -3,22 +3,18 @@ */ package com.sap.cds.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - import com.sap.cds.service.documentai.client.DocumentAiClient; import java.io.ByteArrayInputStream; import java.io.InputStream; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; /* * Temporary tests until the real implementation is done - * */ -@SuppressWarnings("PMD.TooManyStaticImports") + */ class DefaultDocumentAiProcessingServiceTest { public static final String TEST = "test"; @@ -27,45 +23,36 @@ class DefaultDocumentAiProcessingServiceTest { @BeforeEach void setUp() { - documentAiClient = mock(DocumentAiClient.class); - when(documentAiClient.submitDocument(any())).thenReturn("mock-result"); + documentAiClient = Mockito.mock(DocumentAiClient.class); + Mockito.when(documentAiClient.submitDocument(ArgumentMatchers.any())).thenReturn("mock-result"); service = new DefaultDocumentAiProcessingService(documentAiClient); } + // ----- isAvailable() ------- @Test void isAvailableReturnsTrueWhenClientPresent() { - assertThat(service.isAvailable()).isTrue(); + Assertions.assertThat(service.isAvailable()).isTrue(); } @Test void isAvailableReturnsFalseWhenClientNull() { - assertThat(new DefaultDocumentAiProcessingService(null).isAvailable()).isFalse(); + Assertions.assertThat(new DefaultDocumentAiProcessingService(null).isAvailable()).isFalse(); } + // ------- processDocument() ------- @Test void processDocumentCompletesWithoutException() { InputStream content = new ByteArrayInputStream(TEST.getBytes()); - assertThatCode(() -> service.processDocument("job-1", content)).doesNotThrowAnyException(); + Assertions.assertThatCode(() -> service.processDocument("job-1", content)) + .doesNotThrowAnyException(); } @Test void processDocumentHandlesSubmitDocumentException() { - when(documentAiClient.submitDocument(any())).thenThrow(new RuntimeException("submit failed")); - InputStream content = new ByteArrayInputStream(TEST.getBytes()); - assertThatCode(() -> service.processDocument("job-1", content)).doesNotThrowAnyException(); - } - - @Test - void processDocumentHandlesInterruption() throws InterruptedException { + Mockito.when(documentAiClient.submitDocument(ArgumentMatchers.any())) + .thenThrow(new RuntimeException("submit failed")); InputStream content = new ByteArrayInputStream(TEST.getBytes()); - Thread thread = - new Thread( - () -> { - assertThatCode(() -> service.processDocument("job-2", content)) - .doesNotThrowAnyException(); - }); - thread.start(); - thread.interrupt(); - thread.join(); + Assertions.assertThatCode(() -> service.processDocument("job-1", content)) + .doesNotThrowAnyException(); } } From 76eaec306df190d7a8fef8c3af32e4940c1ccb46 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:56:51 +0200 Subject: [PATCH 34/70] bot review fixes --- .../DefaultDocumentAiProcessingService.java | 4 +- .../DocumentAiProcessingException.java | 11 ++++ .../cds/service/ExtractionServiceImpl.java | 25 ++++---- .../service/StatusTransitionValidator.java | 20 +++++++ ...efaultDocumentAiProcessingServiceTest.java | 6 +- .../service/ExtractionServiceImplTest.java | 59 +++++++++++-------- .../StatusTransitionValidatorTest.java | 57 ++++++++++++++++++ 7 files changed, 139 insertions(+), 43 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingException.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index 33b04df..9af70f6 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -30,8 +30,8 @@ public void processDocument(String jobId, InputStream content) { "[sap-document-ai] Document submitted successfully for jobId={}, result={}", jobId, result); - } catch (Exception e) { - logger.error("[sap-document-ai] Extraction failed for jobId={}", jobId, e); + } catch (RuntimeException e) { + throw new DocumentAiProcessingException("Failed to process document for jobId=" + jobId, e); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingException.java b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingException.java new file mode 100644 index 0000000..28deab1 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingException.java @@ -0,0 +1,11 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service; + +public class DocumentAiProcessingException extends RuntimeException { + + public DocumentAiProcessingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 63be6d0..ff15467 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -45,17 +45,24 @@ public void startExtraction( String jobId = createExtractionJob(attachmentId, tenantId); try { - // process document updateStatus(jobId, ExtractionStatus.PROCESSING); documentAiProcessingService.processDocument(jobId, content); updateStatus(jobId, ExtractionStatus.COMPLETED); } catch (Exception e) { logger.error( - "[sap-document-ai] Something went wrong while triggering orchestration - for attachmentId={}, tenantId={}, error={}", + "[sap-document-ai] Something went wrong while triggering orchestration - for attachmentId={}, tenantId={}", attachmentId, tenantId, e); + markJobAsFailed(jobId); + } + } + + private void markJobAsFailed(String jobId) { + try { updateStatus(jobId, ExtractionStatus.FAILED); + } catch (Exception e) { + logger.error("[sap-document-ai] Failed to update status to FAILED for jobId={}", jobId, e); } } @@ -73,26 +80,16 @@ private String createExtractionJob(String attachmentId, String tenantId) { return jobId; } - void updateStatus(String jobId, String status) { - // get current status + private void updateStatus(String jobId, String status) { Result current = persistenceService.run(Select.from(ExtractionJob_.class).byId(jobId)); String currentStatus = current.single(ExtractionJob.class).getStatus(); - // validate status - boolean isStatusUpdateValid = - (currentStatus.equals(ExtractionStatus.PENDING) - && status.equals(ExtractionStatus.PROCESSING)) - || (currentStatus.equals(ExtractionStatus.PROCESSING) - && (status.equals(ExtractionStatus.COMPLETED) - || status.equals(ExtractionStatus.FAILED))); - if (isStatusUpdateValid) { - // update to new status + if (StatusTransitionValidator.isValid(currentStatus, status)) { ExtractionJob extractionJob = ExtractionJob.create(); extractionJob.setStatus(status); persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); } else { - // reject throw new IllegalStateException( "Invalid status transition: " + currentStatus + " -> " + status); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java b/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java new file mode 100644 index 0000000..fa4e5d5 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java @@ -0,0 +1,20 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; + +class StatusTransitionValidator { + + private StatusTransitionValidator() {} + + static boolean isValid(String current, String next) { + return switch (current) { + case ExtractionStatus.PENDING -> ExtractionStatus.PROCESSING.equals(next); + case ExtractionStatus.PROCESSING -> + ExtractionStatus.COMPLETED.equals(next) || ExtractionStatus.FAILED.equals(next); + default -> false; + }; + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java index cc61112..48c7139 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -48,11 +48,11 @@ void processDocumentCompletesWithoutException() { } @Test - void processDocumentHandlesSubmitDocumentException() { + void processDocumentThrowsWhenSubmitDocumentFails() { Mockito.when(documentAiClient.submitDocument(ArgumentMatchers.any())) .thenThrow(new RuntimeException("submit failed")); InputStream content = new ByteArrayInputStream(TEST.getBytes()); - Assertions.assertThatCode(() -> service.processDocument("job-1", content)) - .doesNotThrowAnyException(); + Assertions.assertThatThrownBy(() -> service.processDocument("job-1", content)) + .isInstanceOf(DocumentAiProcessingException.class); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 1de7064..51453ee 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -97,36 +97,47 @@ void startExtractionTransitionsStatusToFailedWhenProcessingThrows() { } @Test - void pendingToCompletedTransitionIsRejected() { - Result pending = jobWithStatus(ExtractionStatus.PENDING); - when(persistenceService.run(any(CqnSelect.class))).thenReturn(pending); - - Assertions.assertThatThrownBy( - () -> extractionService.updateStatus("job-1", ExtractionStatus.COMPLETED)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Invalid status transition"); + void startExtractionFailsWhenJobNotFound() { + Result emptyResult = mock(Result.class); + when(emptyResult.single(ExtractionJob.class)).thenThrow(new RuntimeException("not found")); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult); + + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + verify(persistenceService, never()).run(any(CqnUpdate.class)); + } + + @Test + void startExtractionLogsErrorWhenFailedStatusUpdateAlsoFails() { + Result pendingResult = jobWithStatus(ExtractionStatus.PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(pendingResult); + doThrow(new RuntimeException("simulated failure")) + .when(documentAiProcessingService) + .processDocument(any(), any()); + + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); } @Test - void pendingToFailedTransitionIsRejected() { - Result pending = jobWithStatus(ExtractionStatus.PENDING); - when(persistenceService.run(any(CqnSelect.class))).thenReturn(pending); - - Assertions.assertThatThrownBy( - () -> extractionService.updateStatus("job-1", ExtractionStatus.FAILED)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Invalid status transition"); + void transitionFromCompletedIsRejected() { + Result completedResult = jobWithStatus(ExtractionStatus.COMPLETED); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(completedResult); + + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + verify(persistenceService, never()).run(any(CqnUpdate.class)); } @Test - void processingToPendingTransitionIsRejected() { - Result processing = jobWithStatus(ExtractionStatus.PROCESSING); - when(persistenceService.run(any(CqnSelect.class))).thenReturn(processing); - - Assertions.assertThatThrownBy( - () -> extractionService.updateStatus("job-1", ExtractionStatus.PENDING)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Invalid status transition"); + void invalidTransitionThrowsAndJobMarkedFailed() { + Result pendingResult = jobWithStatus(ExtractionStatus.PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(pendingResult); + + extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); } private Result jobWithStatus(String status) { diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java new file mode 100644 index 0000000..0766fa8 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java @@ -0,0 +1,57 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class StatusTransitionValidatorTest { + + @Test + void pendingToProcessingIsValid() { + Assertions.assertThat( + StatusTransitionValidator.isValid( + ExtractionStatus.PENDING, ExtractionStatus.PROCESSING)) + .isTrue(); + } + + @Test + void processingToCompletedIsValid() { + Assertions.assertThat( + StatusTransitionValidator.isValid( + ExtractionStatus.PROCESSING, ExtractionStatus.COMPLETED)) + .isTrue(); + } + + @Test + void processingToFailedIsValid() { + Assertions.assertThat( + StatusTransitionValidator.isValid(ExtractionStatus.PROCESSING, ExtractionStatus.FAILED)) + .isTrue(); + } + + @Test + void pendingToCompletedIsInvalid() { + Assertions.assertThat( + StatusTransitionValidator.isValid(ExtractionStatus.PENDING, ExtractionStatus.COMPLETED)) + .isFalse(); + } + + @Test + void processingToPendingIsInvalid() { + Assertions.assertThat( + StatusTransitionValidator.isValid( + ExtractionStatus.PROCESSING, ExtractionStatus.PENDING)) + .isFalse(); + } + + @Test + void completedToProcessingIsInvalid() { + Assertions.assertThat( + StatusTransitionValidator.isValid( + ExtractionStatus.COMPLETED, ExtractionStatus.PROCESSING)) + .isFalse(); + } +} From b0e8b951146688ec18d632a2040e727accf1f09c Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:36:20 +0200 Subject: [PATCH 35/70] bot review fixes --- .../cds/service/ExtractionServiceImpl.java | 20 +++++++++++-------- .../service/ExtractionServiceImplTest.java | 15 +++----------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index ff15467..eaf1cf7 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -48,12 +48,16 @@ public void startExtraction( updateStatus(jobId, ExtractionStatus.PROCESSING); documentAiProcessingService.processDocument(jobId, content); updateStatus(jobId, ExtractionStatus.COMPLETED); - } catch (Exception e) { + } catch (IllegalStateException e) { // example: COMPLETED -> FAILED + logger.error("[sap-document-ai] Invalid state transition for jobId={}", jobId, e); + + } catch (Exception e) { // example : PROCESSING -> FAILED logger.error( - "[sap-document-ai] Something went wrong while triggering orchestration - for attachmentId={}, tenantId={}", + "[sap-document-ai] Processing failed for attachmentId={}, tenantId={}", attachmentId, tenantId, e); + markJobAsFailed(jobId); } } @@ -84,14 +88,14 @@ private void updateStatus(String jobId, String status) { Result current = persistenceService.run(Select.from(ExtractionJob_.class).byId(jobId)); String currentStatus = current.single(ExtractionJob.class).getStatus(); - if (StatusTransitionValidator.isValid(currentStatus, status)) { - ExtractionJob extractionJob = ExtractionJob.create(); - extractionJob.setStatus(status); - persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); - logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); - } else { + if (!StatusTransitionValidator.isValid(currentStatus, status)) { throw new IllegalStateException( "Invalid status transition: " + currentStatus + " -> " + status); } + + ExtractionJob extractionJob = ExtractionJob.create(); + extractionJob.setStatus(status); + persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); + logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 51453ee..3149ed6 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -108,7 +108,7 @@ void startExtractionFailsWhenJobNotFound() { } @Test - void startExtractionLogsErrorWhenFailedStatusUpdateAlsoFails() { + void markJobAsFailedIsSkippedWhenTransitionFromPendingToFailedIsInvalid() { Result pendingResult = jobWithStatus(ExtractionStatus.PENDING); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(pendingResult); doThrow(new RuntimeException("simulated failure")) @@ -121,7 +121,8 @@ void startExtractionLogsErrorWhenFailedStatusUpdateAlsoFails() { } @Test - void transitionFromCompletedIsRejected() { + void invalidTransitionIsLoggedAndNoStatusUpdateOccurs() { + // COMPLETED has no valid outgoing transitions — PROCESSING update throws IllegalStateException Result completedResult = jobWithStatus(ExtractionStatus.COMPLETED); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(completedResult); @@ -130,16 +131,6 @@ void transitionFromCompletedIsRejected() { verify(persistenceService, never()).run(any(CqnUpdate.class)); } - @Test - void invalidTransitionThrowsAndJobMarkedFailed() { - Result pendingResult = jobWithStatus(ExtractionStatus.PENDING); - lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(pendingResult); - - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); - - verify(persistenceService, times(1)).run(any(CqnUpdate.class)); - } - private Result jobWithStatus(String status) { ExtractionJob job = ExtractionJob.create(); job.setStatus(status); From cfeddbbfe6775686a21b13bb3d2a05e91d2e69d8 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:46:29 +0200 Subject: [PATCH 36/70] review fixes - I --- .../service/DefaultDocumentAiProcessingService.java | 1 + .../com/sap/cds/service/ExtractionServiceImpl.java | 8 ++++---- .../DocumentAiProcessingException.java | 2 +- .../exceptions/IllegalStatusTransitionException.java | 10 ++++++++++ .../DefaultDocumentAiProcessingServiceTest.java | 1 + .../sap/cds/service/StatusTransitionValidatorTest.java | 8 ++++++++ 6 files changed, 25 insertions(+), 5 deletions(-) rename sap-document-ai/src/main/java/com/sap/cds/service/{ => exceptions}/DocumentAiProcessingException.java (87%) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index 9af70f6..bf69aae 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -4,6 +4,7 @@ package com.sap.cds.service; import com.sap.cds.service.documentai.client.DocumentAiClient; +import com.sap.cds.service.exceptions.DocumentAiProcessingException; import java.io.InputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index eaf1cf7..fee11e0 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -10,6 +10,7 @@ import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; +import com.sap.cds.service.exceptions.IllegalStatusTransitionException; import com.sap.cds.services.persistence.PersistenceService; import java.io.InputStream; import org.slf4j.Logger; @@ -48,9 +49,8 @@ public void startExtraction( updateStatus(jobId, ExtractionStatus.PROCESSING); documentAiProcessingService.processDocument(jobId, content); updateStatus(jobId, ExtractionStatus.COMPLETED); - } catch (IllegalStateException e) { // example: COMPLETED -> FAILED + } catch (IllegalStatusTransitionException e) { // example: COMPLETED -> FAILED logger.error("[sap-document-ai] Invalid state transition for jobId={}", jobId, e); - } catch (Exception e) { // example : PROCESSING -> FAILED logger.error( "[sap-document-ai] Processing failed for attachmentId={}, tenantId={}", @@ -89,8 +89,8 @@ private void updateStatus(String jobId, String status) { String currentStatus = current.single(ExtractionJob.class).getStatus(); if (!StatusTransitionValidator.isValid(currentStatus, status)) { - throw new IllegalStateException( - "Invalid status transition: " + currentStatus + " -> " + status); + throw new IllegalStatusTransitionException( + "Invalid status transition from " + currentStatus + " to " + status); } ExtractionJob extractionJob = ExtractionJob.create(); diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiProcessingException.java similarity index 87% rename from sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingException.java rename to sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiProcessingException.java index 28deab1..173fce6 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiProcessingException.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.service.exceptions; public class DocumentAiProcessingException extends RuntimeException { diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java new file mode 100644 index 0000000..192bbb5 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java @@ -0,0 +1,10 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.exceptions; + +public class IllegalStatusTransitionException extends RuntimeException { + public IllegalStatusTransitionException(String message) { + super(message); + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java index 48c7139..e37c72b 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -4,6 +4,7 @@ package com.sap.cds.service; import com.sap.cds.service.documentai.client.DocumentAiClient; +import com.sap.cds.service.exceptions.DocumentAiProcessingException; import java.io.ByteArrayInputStream; import java.io.InputStream; import org.assertj.core.api.Assertions; diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java index 0766fa8..9b1c147 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java @@ -54,4 +54,12 @@ void completedToProcessingIsInvalid() { ExtractionStatus.COMPLETED, ExtractionStatus.PROCESSING)) .isFalse(); } + + @Test + void sameTransitionTwice() { + Assertions.assertThat( + StatusTransitionValidator.isValid( + ExtractionStatus.PROCESSING, ExtractionStatus.PROCESSING)) + .isFalse(); + } } From 00c7e0305b4498cb312e53ad89b1b6964e9eea7f Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:36:05 +0200 Subject: [PATCH 37/70] review fixes - II --- .../sap/cds/service/ExtractionServiceImpl.java | 8 ++++++++ .../cds/service/StatusTransitionValidator.java | 2 ++ .../cds/service/ExtractionServiceImplTest.java | 18 ++++++++++++++++++ .../service/StatusTransitionValidatorTest.java | 4 ++-- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index fee11e0..8308b15 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -88,6 +88,14 @@ private void updateStatus(String jobId, String status) { Result current = persistenceService.run(Select.from(ExtractionJob_.class).byId(jobId)); String currentStatus = 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); diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java b/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java index fa4e5d5..86ea6eb 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java @@ -10,6 +10,8 @@ class StatusTransitionValidator { private StatusTransitionValidator() {} static boolean isValid(String current, String next) { + if (current.equals(next)) return true; // idempotent + return switch (current) { case ExtractionStatus.PENDING -> ExtractionStatus.PROCESSING.equals(next); case ExtractionStatus.PROCESSING -> diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 3149ed6..cb59f6a 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -107,6 +107,24 @@ void startExtractionFailsWhenJobNotFound() { verify(persistenceService, never()).run(any(CqnUpdate.class)); } + @Test + void updateStatusWithSameStateDoesNotRunJobAgain() { + // Job is already PROCESSING — calling updateStatus(PROCESSING) should be a no-op + Result processingResult = jobWithStatus(ExtractionStatus.PROCESSING); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(processingResult); + doThrow(new RuntimeException("simulated failure")) + .when(documentAiProcessingService) + .processDocument(any(), any()); + + Assertions.assertThatNoException() + .isThrownBy( + () -> extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent)); + + // PENDING→PROCESSING update happened before processDocument threw, + // but PROCESSING→FAILED is skipped because the job reads back as PROCESSING (same state) + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); + } + @Test void markJobAsFailedIsSkippedWhenTransitionFromPendingToFailedIsInvalid() { Result pendingResult = jobWithStatus(ExtractionStatus.PENDING); diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java index 9b1c147..ad64acd 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java @@ -56,10 +56,10 @@ void completedToProcessingIsInvalid() { } @Test - void sameTransitionTwice() { + void sameTransitionIsIdempotent() { Assertions.assertThat( StatusTransitionValidator.isValid( ExtractionStatus.PROCESSING, ExtractionStatus.PROCESSING)) - .isFalse(); + .isTrue(); } } From ac70dac6b10af2ef25e60e3c73b5d2f7ec778ab9 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:25:04 +0200 Subject: [PATCH 38/70] review fixes - III --- .../cds/service/ExtractionServiceImpl.java | 17 +++++---- .../com/sap/cds/service/ExtractionStatus.java | 12 +++++++ .../service/StatusTransitionValidator.java | 9 +++-- .../sap-document-ai/extraction-job.cds | 11 ++---- .../service/ExtractionServiceImplTest.java | 28 ++++++++------- .../StatusTransitionValidatorTest.java | 36 +++++-------------- 6 files changed, 52 insertions(+), 61 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 8308b15..eaffd59 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -3,10 +3,11 @@ */ package com.sap.cds.service; +import static com.sap.cds.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.generated.cds4j.sap.document.ai.ExtractionStatus; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; @@ -46,9 +47,9 @@ public void startExtraction( String jobId = createExtractionJob(attachmentId, tenantId); try { - updateStatus(jobId, ExtractionStatus.PROCESSING); + updateStatus(jobId, PROCESSING); documentAiProcessingService.processDocument(jobId, content); - updateStatus(jobId, ExtractionStatus.COMPLETED); + updateStatus(jobId, COMPLETED); } catch (IllegalStatusTransitionException e) { // example: COMPLETED -> FAILED logger.error("[sap-document-ai] Invalid state transition for jobId={}", jobId, e); } catch (Exception e) { // example : PROCESSING -> FAILED @@ -64,7 +65,7 @@ public void startExtraction( private void markJobAsFailed(String jobId) { try { - updateStatus(jobId, ExtractionStatus.FAILED); + updateStatus(jobId, FAILED); } catch (Exception e) { logger.error("[sap-document-ai] Failed to update status to FAILED for jobId={}", jobId, e); } @@ -74,6 +75,7 @@ private String createExtractionJob(String attachmentId, String tenantId) { ExtractionJob job = ExtractionJob.create(); job.setAttachmentId(attachmentId); job.setTenantId(tenantId); + job.setStatus(PENDING.name()); Result result = persistenceService.run(Insert.into(ExtractionJob_.class).entry(job)); String jobId = result.single(ExtractionJob.class).getId(); @@ -84,9 +86,10 @@ private String createExtractionJob(String attachmentId, String tenantId) { return jobId; } - private void updateStatus(String jobId, String status) { + private void updateStatus(String jobId, ExtractionStatus status) { Result current = persistenceService.run(Select.from(ExtractionJob_.class).byId(jobId)); - String currentStatus = current.single(ExtractionJob.class).getStatus(); + ExtractionStatus currentStatus = + ExtractionStatus.valueOf(current.single(ExtractionJob.class).getStatus()); if (currentStatus.equals(status)) { logger.debug( @@ -102,7 +105,7 @@ private void updateStatus(String jobId, String status) { } ExtractionJob extractionJob = ExtractionJob.create(); - extractionJob.setStatus(status); + extractionJob.setStatus(status.name()); persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java new file mode 100644 index 0000000..265e634 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java @@ -0,0 +1,12 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service; + +public enum ExtractionStatus { + PENDING, + SUBMITTED, + PROCESSING, + COMPLETED, + FAILED; +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java b/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java index 86ea6eb..bd1a26b 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java @@ -3,19 +3,18 @@ */ package com.sap.cds.service; -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; +import static com.sap.cds.service.ExtractionStatus.*; class StatusTransitionValidator { private StatusTransitionValidator() {} - static boolean isValid(String current, String next) { + static boolean isValid(ExtractionStatus current, ExtractionStatus next) { if (current.equals(next)) return true; // idempotent return switch (current) { - case ExtractionStatus.PENDING -> ExtractionStatus.PROCESSING.equals(next); - case ExtractionStatus.PROCESSING -> - ExtractionStatus.COMPLETED.equals(next) || ExtractionStatus.FAILED.equals(next); + case PENDING -> PROCESSING.equals(next); + case PROCESSING -> COMPLETED.equals(next) || FAILED.equals(next); default -> false; }; } diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index 2473643..0c57ba4 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -5,16 +5,9 @@ using { managed } from '@sap/cds/common'; -type ExtractionStatus : String enum { - Pending; - Processing; - Completed; - Failed; -} - @assert.unique: { attachmentId: [attachmentId] } entity ExtractionJob : cuid, managed { attachmentId : String; - status : ExtractionStatus default #Pending; - tenantId : String; + status : String; + tenantId:String; } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index cb59f6a..6401c4f 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -3,13 +3,13 @@ */ package com.sap.cds.service; +import static com.sap.cds.service.ExtractionStatus.*; import static org.mockito.ArgumentCaptor.forClass; import static org.mockito.Mockito.*; import com.sap.cds.Result; import com.sap.cds.Struct; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; import com.sap.cds.ql.cqn.CqnInsert; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.CqnUpdate; @@ -47,8 +47,8 @@ void setUp() { lenient().when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); lenient().when(documentAiProcessingService.isAvailable()).thenReturn(true); - Result pendingResult = jobWithStatus(ExtractionStatus.PENDING); - Result processingResult = jobWithStatus(ExtractionStatus.PROCESSING); + Result pendingResult = jobWithStatus(PENDING); + Result processingResult = jobWithStatus(PROCESSING); lenient() .when(persistenceService.run(any(CqnSelect.class))) .thenReturn(pendingResult, processingResult); @@ -75,13 +75,14 @@ void startExtractionCreatesOneJobWithCorrectFields() { Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); Assertions.assertThat(inserted.getAttachmentId()).isEqualTo(ATT_123); Assertions.assertThat(inserted.getTenantId()).isEqualTo(TENANT_1); + Assertions.assertThat(inserted.getStatus()).isEqualTo(PENDING.name()); } @Test void startExtractionTransitionsStatusToProcessingThenCompleted() { extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); List updates = captureStatusUpdates(2); - assertStatusSequence(updates, ExtractionStatus.PROCESSING, ExtractionStatus.COMPLETED); + assertStatusSequence(updates, PROCESSING, COMPLETED); } @Test @@ -93,7 +94,7 @@ void startExtractionTransitionsStatusToFailedWhenProcessingThrows() { extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); List updates = captureStatusUpdates(2); - assertStatusSequence(updates, ExtractionStatus.PROCESSING, ExtractionStatus.FAILED); + assertStatusSequence(updates, PROCESSING, FAILED); } @Test @@ -110,7 +111,7 @@ void startExtractionFailsWhenJobNotFound() { @Test void updateStatusWithSameStateDoesNotRunJobAgain() { // Job is already PROCESSING — calling updateStatus(PROCESSING) should be a no-op - Result processingResult = jobWithStatus(ExtractionStatus.PROCESSING); + Result processingResult = jobWithStatus(PROCESSING); when(persistenceService.run(any(CqnSelect.class))).thenReturn(processingResult); doThrow(new RuntimeException("simulated failure")) .when(documentAiProcessingService) @@ -127,7 +128,7 @@ void updateStatusWithSameStateDoesNotRunJobAgain() { @Test void markJobAsFailedIsSkippedWhenTransitionFromPendingToFailedIsInvalid() { - Result pendingResult = jobWithStatus(ExtractionStatus.PENDING); + Result pendingResult = jobWithStatus(PENDING); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(pendingResult); doThrow(new RuntimeException("simulated failure")) .when(documentAiProcessingService) @@ -141,7 +142,7 @@ void markJobAsFailedIsSkippedWhenTransitionFromPendingToFailedIsInvalid() { @Test void invalidTransitionIsLoggedAndNoStatusUpdateOccurs() { // COMPLETED has no valid outgoing transitions — PROCESSING update throws IllegalStateException - Result completedResult = jobWithStatus(ExtractionStatus.COMPLETED); + Result completedResult = jobWithStatus(COMPLETED); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(completedResult); extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); @@ -149,9 +150,9 @@ void invalidTransitionIsLoggedAndNoStatusUpdateOccurs() { verify(persistenceService, never()).run(any(CqnUpdate.class)); } - private Result jobWithStatus(String status) { + private Result jobWithStatus(ExtractionStatus status) { ExtractionJob job = ExtractionJob.create(); - job.setStatus(status); + job.setStatus(status.name()); Result result = mock(Result.class); lenient().when(result.single(ExtractionJob.class)).thenReturn(job); return result; @@ -163,12 +164,13 @@ private List captureStatusUpdates(int expectedCount) { return captor.getAllValues(); } - private void assertStatusSequence(List updates, String first, String second) { + private void assertStatusSequence( + List updates, ExtractionStatus first, ExtractionStatus second) { Assertions.assertThat( Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(first); + .isEqualTo(first.name()); Assertions.assertThat( Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(second); + .isEqualTo(second.name()); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java index ad64acd..43bb19d 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java @@ -3,7 +3,8 @@ */ package com.sap.cds.service; -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionStatus; +import static com.sap.cds.service.ExtractionStatus.*; + import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; @@ -11,55 +12,36 @@ class StatusTransitionValidatorTest { @Test void pendingToProcessingIsValid() { - Assertions.assertThat( - StatusTransitionValidator.isValid( - ExtractionStatus.PENDING, ExtractionStatus.PROCESSING)) - .isTrue(); + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, PROCESSING)).isTrue(); } @Test void processingToCompletedIsValid() { - Assertions.assertThat( - StatusTransitionValidator.isValid( - ExtractionStatus.PROCESSING, ExtractionStatus.COMPLETED)) - .isTrue(); + Assertions.assertThat(StatusTransitionValidator.isValid(PROCESSING, COMPLETED)).isTrue(); } @Test void processingToFailedIsValid() { - Assertions.assertThat( - StatusTransitionValidator.isValid(ExtractionStatus.PROCESSING, ExtractionStatus.FAILED)) - .isTrue(); + Assertions.assertThat(StatusTransitionValidator.isValid(PROCESSING, FAILED)).isTrue(); } @Test void pendingToCompletedIsInvalid() { - Assertions.assertThat( - StatusTransitionValidator.isValid(ExtractionStatus.PENDING, ExtractionStatus.COMPLETED)) - .isFalse(); + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, COMPLETED)).isFalse(); } @Test void processingToPendingIsInvalid() { - Assertions.assertThat( - StatusTransitionValidator.isValid( - ExtractionStatus.PROCESSING, ExtractionStatus.PENDING)) - .isFalse(); + Assertions.assertThat(StatusTransitionValidator.isValid(PROCESSING, PENDING)).isFalse(); } @Test void completedToProcessingIsInvalid() { - Assertions.assertThat( - StatusTransitionValidator.isValid( - ExtractionStatus.COMPLETED, ExtractionStatus.PROCESSING)) - .isFalse(); + Assertions.assertThat(StatusTransitionValidator.isValid(COMPLETED, PROCESSING)).isFalse(); } @Test void sameTransitionIsIdempotent() { - Assertions.assertThat( - StatusTransitionValidator.isValid( - ExtractionStatus.PROCESSING, ExtractionStatus.PROCESSING)) - .isTrue(); + Assertions.assertThat(StatusTransitionValidator.isValid(PROCESSING, PROCESSING)).isTrue(); } } From c1fa3e2af4f616dba776c78a83160c1d4c8bcdc0 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:46:06 +0200 Subject: [PATCH 39/70] initial commit bot review fixes Remove MediaData from the core # Conflicts: # sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java # sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java # sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java # sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds # sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java # sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java # sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java --- sap-document-ai/pom.xml | 7 + .../AttachmentEventHandlerRegistration.java | 2 +- .../cds/handlers/AttachmentEventHandler.java | 163 +++++++++++++++- .../DefaultDocumentAiProcessingService.java | 18 +- .../service/DocumentAiProcessingService.java | 4 +- .../sap/cds/service/ExtractionService.java | 4 +- .../cds/service/ExtractionServiceImpl.java | 34 +++- .../service/StatusTransitionValidator.java | 3 +- .../client/DefaultDocumentAiClient.java | 123 +++++++++++- .../documentai/client/DocumentAiClient.java | 4 +- .../sap/cds/service/model/DocumentInput.java | 9 + .../sap-document-ai/extraction-job.cds | 1 + .../sap/cds/AttachmentEventHandlerTest.java | 181 +++++++++++++++--- ...efaultDocumentAiProcessingServiceTest.java | 30 ++- .../service/ExtractionServiceImplTest.java | 155 +++++++-------- .../StatusTransitionValidatorTest.java | 19 +- 16 files changed, 602 insertions(+), 155 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index e628af3..f083272 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -54,6 +54,12 @@ cds-feature-attachments provided + + org.apache.httpcomponents + httpmime + 4.5.14 + compile + org.junit.jupiter junit-jupiter @@ -169,6 +175,7 @@ **/feature/documentai/generated/** + **/service/documentai/client/DefaultDocumentAiClient.class diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index a264161..5aacb98 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -56,7 +56,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { new ExtractionServiceImpl(persistenceService, documentAiProcessingService); // register event handler with CAP runtime - configurer.eventHandler(new AttachmentEventHandler(extractionService)); + configurer.eventHandler(new AttachmentEventHandler(extractionService, persistenceService)); } static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 330c8aa..567f5eb 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -3,45 +3,190 @@ */ package com.sap.cds.handlers; +import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsElementNotFoundException; +import com.sap.cds.reflect.CdsEntity; import com.sap.cds.service.ExtractionService; +import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.services.changeset.ChangeSetListener; +import com.sap.cds.services.draft.Drafts; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; import java.io.InputStream; +import java.util.*; +import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @ServiceName(value = "*", type = AttachmentService.class) public class AttachmentEventHandler implements EventHandler { - private static final Logger log = LoggerFactory.getLogger(AttachmentEventHandler.class); + private static final Logger logger = LoggerFactory.getLogger(AttachmentEventHandler.class); private final ExtractionService extractionService; + private final PersistenceService persistenceService; - public AttachmentEventHandler(ExtractionService extractionService) { + public AttachmentEventHandler( + ExtractionService extractionService, PersistenceService persistenceService) { this.extractionService = extractionService; + this.persistenceService = persistenceService; } @After(event = AttachmentService.EVENT_CREATE_ATTACHMENT) public void afterCreateAttachment(AttachmentCreateEventContext context) { String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); String tenantId = context.getUserInfo().getTenant(); + String contentId = context.getContentId(); + CdsRuntime cdsRuntime = context.getCdsRuntime(); + if (attachmentId == null) { - log.warn("[sap-document-ai] attachmentId is null, skipping extraction"); + logger.warn("[sap-document-ai] attachmentId is null, skipping extraction"); return; } - String contentId = context.getContentId(); - InputStream content = context.getData().getContent(); + MediaData contextData = context.getData(); + CdsEntity attachmentEntity = context.getAttachmentEntity(); + + context + .getChangeSetContext() + .register( + new ChangeSetListener() { + + @Override + public void afterClose(boolean completed) { + logger.info("[sap-document-ai] afterClose fired, completed={}", completed); + if (!completed) return; + + cdsRuntime + .requestContext() + .run( + (Consumer) + requestCtx -> + cdsRuntime + .changeSetContext() + .run( + changeSetCtx -> { + String fileName = contextData.getFileName(); + String mimeType = contextData.getMimeType(); + triggerExtraction( + attachmentEntity, + contentId, + attachmentId, + tenantId, + fileName, + mimeType); + })); + } + }); + } + + private void triggerExtraction( + CdsEntity attachmentEntity, + String contentId, + String attachmentId, + String tenantId, + String fileName, + String mimeType) { + Optional row = getAttachment(attachmentEntity, contentId); + + if (row.isEmpty()) { + logger.warn("[sap-document-ai] No attachment found for contentId={}, skipping", contentId); + return; + } + + Attachments attachment = row.get(); - log.info( - "[sap-document-ai] Attachment persisted. Triggering extraction for attachmentId={}, contentId={}, tenantId={}", + InputStream attachmentContent = attachment.getContent(); + + if (attachmentContent == null) { + logger.warn("[sap-document-ai] Content is null for contentId={}, skipping", contentId); + return; + } + + DocumentInput documentInput = + new DocumentInput(fileName, contentId, mimeType, attachmentContent); + + logger.info( + "[sap-document-ai] Triggering extraction for attachmentId={}, contentId={}", attachmentId, + contentId); + + extractionService.startExtraction(attachmentId, documentInput, tenantId); + } + + private Optional getAttachment(CdsEntity attachmentEntity, String contentId) { + logger.info( + "Started finding attachment {} of entity {}.", contentId, - tenantId); - extractionService.startExtraction(attachmentId, contentId, tenantId, content); + attachmentEntity.getQualifiedName()); + + List selectionResults = selectData(attachmentEntity, contentId); + + for (SelectionResult result : selectionResults) { + long rowCount = result.result().rowCount(); + + if (rowCount <= 0) { + logger.info( + "No attachment {} found in entity {}.", contentId, result.entity().getQualifiedName()); + continue; + } + + if (rowCount > 1) { + throw new IllegalStateException( + "More than one attachment with contentId %s.".formatted(contentId)); + } + + Attachments found = result.result().single(Attachments.class); + if (found != null) { + return Optional.of(found); + } + } + + return Optional.empty(); } + + private List selectData(CdsEntity attachmentEntity, String contentId) { + List result = new ArrayList<>(); + try { + CdsEntity entity = (CdsEntity) attachmentEntity.getTargetOf(Drafts.SIBLING_ENTITY); + Result selectionResult = readData(contentId, entity); + result.add(new SelectionResult(entity, selectionResult)); + } catch (CdsElementNotFoundException ignored) { + // no sibling found nothing to select + } + Result selectionResult = readData(contentId, attachmentEntity); + result.add(new SelectionResult(attachmentEntity, selectionResult)); + + return result; + } + + private Result readData(String contentId, CdsEntity entity) { + CqnSelect select = + Select.from(entity) + .columns(Attachments.CONTENT_ID, Attachments.CONTENT) + .where(e -> e.get(Attachments.CONTENT_ID).eq(contentId)); + + Result result = persistenceService.run(select); + result + .streamOf(Attachments.class) + .forEach( + attachment -> + logger.debug( + "Found attachment {} in entity {}.", + attachment.getContentId(), + entity.getQualifiedName())); + return result; + } + + private record SelectionResult(CdsEntity entity, Result result) {} } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index bf69aae..175a0a7 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -5,7 +5,7 @@ import com.sap.cds.service.documentai.client.DocumentAiClient; import com.sap.cds.service.exceptions.DocumentAiProcessingException; -import java.io.InputStream; +import com.sap.cds.service.model.DocumentInput; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,16 +22,20 @@ public DefaultDocumentAiProcessingService(DocumentAiClient documentAiClient) { } @Override - public void processDocument(String jobId, InputStream content) { + public String processDocument(String jobId, DocumentInput documentInput) { logger.info( - "[sap-document-ai] Processing document for jobId={} and content={}", jobId, content); + "[sap-document-ai] Processing document for jobId={}, fileName={}", + jobId, + documentInput.fileName()); + try { - String result = documentAiClient.submitDocument(content); + String documentAiJobId = documentAiClient.submitDocument(documentInput); logger.info( - "[sap-document-ai] Document submitted successfully for jobId={}, result={}", + "[sap-document-ai] Document submitted successfully for jobId={}, DIE jobId={}", jobId, - result); - } catch (RuntimeException e) { + documentAiJobId); + return documentAiJobId; + } catch (Exception e) { throw new DocumentAiProcessingException("Failed to process document for jobId=" + jobId, e); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java index b212b35..487c9b0 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java @@ -3,11 +3,11 @@ */ package com.sap.cds.service; -import java.io.InputStream; +import com.sap.cds.service.model.DocumentInput; public interface DocumentAiProcessingService { boolean isAvailable(); - void processDocument(String jobId, InputStream content); + String processDocument(String jobId, DocumentInput documentInput); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index d15f7ec..a8e54fa 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -3,9 +3,9 @@ */ package com.sap.cds.service; -import java.io.InputStream; +import com.sap.cds.service.model.DocumentInput; public interface ExtractionService { - void startExtraction(String attachmentId, String contentId, String tenantId, InputStream content); + void startExtraction(String attachmentId, DocumentInput documentInput, String tenantId); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index eaffd59..3900394 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -12,8 +12,8 @@ import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; import com.sap.cds.service.exceptions.IllegalStatusTransitionException; +import com.sap.cds.service.model.DocumentInput; import com.sap.cds.services.persistence.PersistenceService; -import java.io.InputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,8 +32,7 @@ public ExtractionServiceImpl( } @Override - public void startExtraction( - String attachmentId, String contentId, String tenantId, InputStream content) { + public void startExtraction(String attachmentId, DocumentInput documentInput, String tenantId) { logger.info( "[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, @@ -45,11 +44,14 @@ public void startExtraction( } String jobId = createExtractionJob(attachmentId, tenantId); - try { - updateStatus(jobId, PROCESSING); - documentAiProcessingService.processDocument(jobId, content); - updateStatus(jobId, COMPLETED); + String documentAiJobId = documentAiProcessingService.processDocument(jobId, documentInput); + updateStatus(jobId, SUBMITTED); + updateDocumentAiJobId(jobId, documentAiJobId); + + // TODO: transition to PROCESSING and COMPLETED via async polling callback, not here + // updateStatus(jobId, PROCESSING); + // updateStatus(jobId, COMPLETED); } catch (IllegalStatusTransitionException e) { // example: COMPLETED -> FAILED logger.error("[sap-document-ai] Invalid state transition for jobId={}", jobId, e); } catch (Exception e) { // example : PROCESSING -> FAILED @@ -58,7 +60,6 @@ public void startExtraction( attachmentId, tenantId, e); - markJobAsFailed(jobId); } } @@ -107,6 +108,21 @@ private void updateStatus(String jobId, ExtractionStatus status) { ExtractionJob extractionJob = ExtractionJob.create(); extractionJob.setStatus(status.name()); persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); - logger.info("[sap-document-ai] ExtractionJob jobId={} status updated to {}", jobId, status); + logger.info( + "[sap-document-ai] ExtractionJob jobId={} status updated from {} to {}", + jobId, + currentStatus, + status); + } + + private void updateDocumentAiJobId(String jobId, String documentAiJobId) { + ExtractionJob extractionJob = ExtractionJob.create(); + extractionJob.setDocumentAiJobId(documentAiJobId); + + persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); + logger.info( + "[sap-document-ai] ExtractionJob jobId={} has been updated with documentAiJobId now={}", + jobId, + documentAiJobId); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java b/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java index bd1a26b..2dea219 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java @@ -13,7 +13,8 @@ static boolean isValid(ExtractionStatus current, ExtractionStatus next) { if (current.equals(next)) return true; // idempotent return switch (current) { - case PENDING -> PROCESSING.equals(next); + case PENDING -> SUBMITTED.equals(next) || FAILED.equals(next); + case SUBMITTED -> PROCESSING.equals(next) || FAILED.equals(next); case PROCESSING -> COMPLETED.equals(next) || FAILED.equals(next); default -> false; }; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java index 2991751..e72c497 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java @@ -3,21 +3,32 @@ */ package com.sap.cds.service.documentai.client; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.service.model.DocumentInput; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.util.List; +import java.util.Map; import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class DefaultDocumentAiClient implements DocumentAiClient { private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiClient.class); - + private static final String DOCUMENT_AI_API_PATH = "/document-information-extraction/v1"; + private static final ObjectMapper objectMapper = new ObjectMapper(); private final HttpDestination destination; - - // TODO: Remove this suppress warning once httpClient is used in submitDocument - @SuppressWarnings("PMD.UnusedPrivateField") private final HttpClient httpClient; public DefaultDocumentAiClient(HttpDestination destination, HttpClient httpClient) { @@ -26,9 +37,105 @@ public DefaultDocumentAiClient(HttpDestination destination, HttpClient httpClien } @Override - public String submitDocument(InputStream content) { - URI baseUri = destination.getUri(); - logger.info("[sap-document-ai] Submitting document to DIE at url={}", baseUri); - return null; + public String submitDocument(DocumentInput documentInput) { + URI submitUri = buildSubmitUri(); + HttpPost request = buildSubmitRequest(documentInput, submitUri); + String body = executeRequest(request, submitUri); + return extractJobId(body); + } + + private URI buildSubmitUri() { + return destination.getUri().resolve(DOCUMENT_AI_API_PATH + "/document/jobs"); + } + + private HttpPost buildSubmitRequest(DocumentInput documentInput, URI submitUri) { + InputStream content = documentInput.content(); + logger.info( + "[sap-document-ai] ----------Submitting document to DIE at url={} with content={}", + submitUri, + content); + + byte[] bytes; + try { + bytes = content.readAllBytes(); + logger.info( + "[sap-document-ai] fileName={}, mimeType={}, size={} bytes", + documentInput.fileName(), + documentInput.mimeType(), + bytes.length); + } catch (IOException e) { + throw new RuntimeException("Failed to read document content", e); + } + + String optionsJson = buildOptionsJson(); + + ContentType contentType = + documentInput.mimeType() != null + ? ContentType.create(documentInput.mimeType()) + : ContentType.APPLICATION_OCTET_STREAM; + HttpPost request = new HttpPost(submitUri); + request.setEntity( + MultipartEntityBuilder.create() + .addBinaryBody( + "file", new ByteArrayInputStream(bytes), contentType, documentInput.fileName()) + .addTextBody("options", optionsJson, ContentType.APPLICATION_JSON) + .build()); + + logger.info("[sap-document-ai] POST {} | Headers: {}", submitUri, request.getAllHeaders()); + return request; + } + + private String executeRequest(HttpPost request, URI submitUri) { + + try (CloseableHttpResponse response = (CloseableHttpResponse) httpClient.execute(request)) { + + String body = EntityUtils.toString(response.getEntity()); + + int statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode < 200 || statusCode >= 300) { + throw new RuntimeException("DIE request failed. Status=" + statusCode + ", body=" + body); + } + + return body; + + } catch (Exception e) { + throw new RuntimeException("Failed to submit document to DIE at " + submitUri, e); + } + } + + private String extractJobId(String body) { + try { + JsonNode json = objectMapper.readTree(body); + + if (!json.has("id")) { + throw new RuntimeException("Unexpected DIE response. body=" + body); + } + + String jobId = json.get("id").asText(); + logger.info("[sap-document-ai] Document submitted successfully, DIE jobId={}", jobId); + return jobId; + + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to parse DIE response", e); + } + } + + private String buildOptionsJson() { + // TODO: Currently options are hard-coded. Make these dynamic + Map options = + Map.of( + "clientId", "default", + "documentType", "invoice", + "receivedDate", "2020-02-17", + "schemaId", "cf8cc8a9-1eee-42d9-9a3e-507a61baac23", + "templateId", "detect", + "candidateTemplateIds", List.of(), + "enrichment", Map.of()); + try { + return objectMapper.writeValueAsString(options); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize options", e); + } } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java index ed61919..2f436df 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java @@ -3,8 +3,8 @@ */ package com.sap.cds.service.documentai.client; -import java.io.InputStream; +import com.sap.cds.service.model.DocumentInput; public interface DocumentAiClient { - String submitDocument(InputStream content); + String submitDocument(DocumentInput documentInput); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java new file mode 100644 index 0000000..98a99f8 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java @@ -0,0 +1,9 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.model; + +import java.io.InputStream; + +public record DocumentInput( + String fileName, String contentId, String mimeType, InputStream content) {} diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index 0c57ba4..a9af2f2 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -10,4 +10,5 @@ entity ExtractionJob : cuid, managed { attachmentId : String; status : String; tenantId:String; + DocumentAiJobId : String; } diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index 3b81ac6..5434d2c 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -6,60 +6,189 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.handlers.AttachmentEventHandler; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsDefinition; +import com.sap.cds.reflect.CdsElementNotFoundException; +import com.sap.cds.reflect.CdsEntity; import com.sap.cds.service.ExtractionService; +import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.services.changeset.ChangeSetContext; +import com.sap.cds.services.changeset.ChangeSetListener; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.request.UserInfo; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.ChangeSetContextRunner; +import com.sap.cds.services.runtime.RequestContextRunner; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.util.Map; +import java.util.function.Consumer; +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 AttachmentEventHandlerTest { + private static final String ATTACHMENT_ID = "test-attachment-id"; + private static final String CONTENT_ID = "test-content-id"; + private static final String TENANT_ID = "test-tenant"; + private static final String TEST_ENTITY = "test.Entity"; + @Mock ExtractionService extractionService; + @Mock PersistenceService persistenceService; + @Mock AttachmentCreateEventContext context; + @Mock UserInfo userInfo; + @Mock MediaData mediaData; + @Mock DocumentInput documentInput; + @Mock CdsEntity attachmentEntity; + @Mock CdsRuntime cdsRuntime; + @Mock RequestContextRunner requestContextRunner; + @Mock ChangeSetContextRunner changeSetContextRunner; + @Mock ChangeSetContext changeSetContext; - @Test - void afterCreateAttachmentTriggersOrchestrationWithTenant() { - // Arrange - AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); - AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); - UserInfo userInfo = mock(UserInfo.class); - MediaData mediaData = mock(MediaData.class); - InputStream content = new ByteArrayInputStream("test".getBytes()); - when(context.getAttachmentIds()).thenReturn(Map.of("ID", "test-attachment-id")); - when(context.getContentId()).thenReturn("test-content-id"); + AttachmentEventHandler handler; + + @BeforeEach + void setUp() { + handler = new AttachmentEventHandler(extractionService, persistenceService); + + when(context.getAttachmentIds()).thenReturn(Map.of("ID", ATTACHMENT_ID)); + when(context.getContentId()).thenReturn(CONTENT_ID); when(context.getUserInfo()).thenReturn(userInfo); - when(context.getData()).thenReturn(mediaData); - when(mediaData.getContent()).thenReturn(content); - when(userInfo.getTenant()).thenReturn("test-tenant"); + when(context.getCdsRuntime()).thenReturn(cdsRuntime); - // Act - handler.afterCreateAttachment(context); + // no draft sibling for this entity + lenient() + .doThrow(new CdsElementNotFoundException("no sibling", mock(CdsDefinition.class))) + .when(attachmentEntity) + .getTargetOf(any()); + + // wire runtime to execute lambdas inline (synchronously) + lenient().when(cdsRuntime.requestContext()).thenReturn(requestContextRunner); + lenient() + .doAnswer( + inv -> { + inv.getArgument(0, Consumer.class).accept(mock(RequestContext.class)); + return null; + }) + .when(requestContextRunner) + .run(any(Consumer.class)); + lenient().when(cdsRuntime.changeSetContext()).thenReturn(changeSetContextRunner); + lenient() + .doAnswer( + inv -> { + inv.getArgument(0, Consumer.class).accept(mock(ChangeSetContext.class)); + return null; + }) + .when(changeSetContextRunner) + .run(any(Consumer.class)); + } + + @Test + void shouldStartExtractionAfterSuccessfulCommit() { + mockAttachmentContext(); + mockExtractionContext(); + when(context.getChangeSetContext()).thenReturn(changeSetContext); + InputStream content = new ByteArrayInputStream("pdf-bytes".getBytes()); + mockAttachmentLookup(createAttachmentResult(content)); + + commitChangeSet(); - // Assert verify(extractionService) - .startExtraction("test-attachment-id", "test-content-id", "test-tenant", content); + .startExtraction(eq(ATTACHMENT_ID), any(DocumentInput.class), eq(TENANT_ID)); + } + + @Test + void shouldNotStartExtractionWhenChangeSetIsRolledBack() { + when(context.getChangeSetContext()).thenReturn(changeSetContext); + rollbackChangeSet(); + verify(extractionService, never()).startExtraction(any(), any(), any()); + } + + @Test + void shouldNotStartExtractionWhenAttachmentRecordDoesNotExist() { + Result emptyResult = mock(Result.class); + when(emptyResult.rowCount()).thenReturn(0L); + mockAttachmentContext(); + mockExtractionContext(); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult); + when(attachmentEntity.getQualifiedName()).thenReturn(TEST_ENTITY); + when(context.getChangeSetContext()).thenReturn(changeSetContext); + + commitChangeSet(); + + verify(extractionService, never()).startExtraction(any(), any(), any()); } @Test - void afterCreateAttachmentSkipsExtractionWhenAttachmentIdIsNull() { - // Arrange - AttachmentEventHandler handler = new AttachmentEventHandler(extractionService); - AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); - UserInfo userInfo = mock(UserInfo.class); + void shouldNotStartExtractionWhenAttachmentContentIsMissing() { + mockAttachmentContext(); + mockExtractionContext(); + mockAttachmentLookup(createAttachmentResult(null)); + when(context.getChangeSetContext()).thenReturn(changeSetContext); + commitChangeSet(); + verify(extractionService, never()).startExtraction(any(), any(), any()); + } + + @Test + void shouldNotRegisterChangeSetListenerWhenAttachmentIdIsMissing() { when(context.getAttachmentIds()).thenReturn(Map.of()); - when(context.getUserInfo()).thenReturn(userInfo); + handler.afterCreateAttachment(context); + verify(changeSetContext, never()).register(any()); + verify(extractionService, never()).startExtraction(any(), any(), any()); + } - // Act + private void mockAttachmentContext() { + when(context.getData()).thenReturn(mediaData); + when(context.getAttachmentEntity()).thenReturn(attachmentEntity); + } + + private void mockExtractionContext() { + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(mediaData.getFileName()).thenReturn("dummy_invoice.pdf"); + when(mediaData.getMimeType()).thenReturn("application/pdf"); + } + + private ChangeSetListener captureRegisteredChangeSetListener() { handler.afterCreateAttachment(context); + ArgumentCaptor captor = ArgumentCaptor.forClass(ChangeSetListener.class); + verify(changeSetContext).register(captor.capture()); + return captor.getValue(); + } + + private Result createAttachmentResult(InputStream content) { + Attachments attachment = Attachments.create(); + attachment.setContentId(CONTENT_ID); + attachment.setContent(content); + + Result result = mock(Result.class); + doReturn(1L).when(result).rowCount(); + doReturn(attachment).when(result).single(Attachments.class); + doReturn(java.util.stream.Stream.of(attachment)).when(result).streamOf(Attachments.class); + return result; + } + + private void rollbackChangeSet() { + ChangeSetListener listener = captureRegisteredChangeSetListener(); + listener.afterClose(false); + } + + private void commitChangeSet() { + ChangeSetListener listener = captureRegisteredChangeSetListener(); + listener.afterClose(true); + } - // Assert - verify(extractionService, never()).startExtraction(any(), any(), any(), any()); + private void mockAttachmentLookup(Result result) { + when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); + when(attachmentEntity.getQualifiedName()).thenReturn(TEST_ENTITY); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java index e37c72b..39f984c 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -3,14 +3,16 @@ */ package com.sap.cds.service; +import static org.mockito.ArgumentMatchers.any; + import com.sap.cds.service.documentai.client.DocumentAiClient; import com.sap.cds.service.exceptions.DocumentAiProcessingException; +import com.sap.cds.service.model.DocumentInput; import java.io.ByteArrayInputStream; -import java.io.InputStream; +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.ArgumentMatchers; import org.mockito.Mockito; /* @@ -18,15 +20,27 @@ */ class DefaultDocumentAiProcessingServiceTest { - public static final String TEST = "test"; + public static final String TEST_PDF = "test.pdf"; + public static final String CNT_ID_1 = "cnt_id_1"; + 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(ArgumentMatchers.any())).thenReturn("mock-result"); + Mockito.when(documentAiClient.submitDocument(any())).thenReturn(MOCK_RESULT); service = new DefaultDocumentAiProcessingService(documentAiClient); + documentInput = + new DocumentInput( + TEST_PDF, + CNT_ID_1, + CONTENT_TYPE, + new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8))); } // ----- isAvailable() ------- @@ -43,17 +57,15 @@ void isAvailableReturnsFalseWhenClientNull() { // ------- processDocument() ------- @Test void processDocumentCompletesWithoutException() { - InputStream content = new ByteArrayInputStream(TEST.getBytes()); - Assertions.assertThatCode(() -> service.processDocument("job-1", content)) + Assertions.assertThatCode(() -> service.processDocument(JOB_1, documentInput)) .doesNotThrowAnyException(); } @Test void processDocumentThrowsWhenSubmitDocumentFails() { - Mockito.when(documentAiClient.submitDocument(ArgumentMatchers.any())) + Mockito.when(documentAiClient.submitDocument(any())) .thenThrow(new RuntimeException("submit failed")); - InputStream content = new ByteArrayInputStream(TEST.getBytes()); - Assertions.assertThatThrownBy(() -> service.processDocument("job-1", content)) + Assertions.assertThatThrownBy(() -> service.processDocument(JOB_1, documentInput)) .isInstanceOf(DocumentAiProcessingException.class); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 6401c4f..576e486 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -4,6 +4,7 @@ package com.sap.cds.service; import static com.sap.cds.service.ExtractionStatus.*; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentCaptor.forClass; import static org.mockito.Mockito.*; @@ -13,11 +14,12 @@ 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.service.model.DocumentInput; import com.sap.cds.services.persistence.PersistenceService; import java.io.ByteArrayInputStream; -import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.List; -import org.assertj.core.api.Assertions; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -31,146 +33,145 @@ class ExtractionServiceImplTest { static final String TENANT_1 = "tenant-1"; static final String ATT_123 = "att-123"; static final String CNT_123 = "cnt-123"; + static final String DIE_JOB_ID = "die-job-123"; + public static final String TEST_PDF = "test.pdf"; + public static final String CONTENT_TYPE = "application/pdf"; + public static final String TEST_CONTENT = "test-content"; @Mock PersistenceService persistenceService; @Mock DocumentAiProcessingService documentAiProcessingService; @Mock Result insertResult; + DocumentInput documentInput; ExtractionServiceImpl extractionService; - InputStream mockContent; @BeforeEach void setUp() { - 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); - lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); - lenient().when(documentAiProcessingService.isAvailable()).thenReturn(true); - Result pendingResult = jobWithStatus(PENDING); - Result processingResult = jobWithStatus(PROCESSING); - lenient() - .when(persistenceService.run(any(CqnSelect.class))) - .thenReturn(pendingResult, processingResult); - mockContent = new ByteArrayInputStream("test-content".getBytes()); + when(documentAiProcessingService.isAvailable()).thenReturn(true); + documentInput = + new DocumentInput( + TEST_PDF, + CNT_123, + CONTENT_TYPE, + new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8))); extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); } @Test void startExtractionDoesNothingWhenServiceUnavailable() { when(documentAiProcessingService.isAvailable()).thenReturn(false); - - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); - + extractionService.startExtraction(ATT_123, documentInput, TENANT_1); verify(persistenceService, never()).run(any(CqnInsert.class)); } @Test void startExtractionCreatesOneJobWithCorrectFields() { - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + mockAllDatabaseCalls(); + mockSuccessfulProcessing(); + extractionService.startExtraction(ATT_123, documentInput, TENANT_1); ArgumentCaptor insertCaptor = forClass(CqnInsert.class); verify(persistenceService, times(1)).run(insertCaptor.capture()); ExtractionJob inserted = Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); - Assertions.assertThat(inserted.getAttachmentId()).isEqualTo(ATT_123); - Assertions.assertThat(inserted.getTenantId()).isEqualTo(TENANT_1); - Assertions.assertThat(inserted.getStatus()).isEqualTo(PENDING.name()); + assertThat(inserted.getAttachmentId()).isEqualTo(ATT_123); + assertThat(inserted.getTenantId()).isEqualTo(TENANT_1); + assertThat(inserted.getStatus()).isEqualTo(PENDING.name()); } @Test - void startExtractionTransitionsStatusToProcessingThenCompleted() { - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); - List updates = captureStatusUpdates(2); - assertStatusSequence(updates, PROCESSING, COMPLETED); - } + void startExtractionStoresDocumentAiJobIdAndUpdatesStatusToSubmitted() { + mockAllDatabaseCalls(); + mockSuccessfulProcessing(); + mockStatusResult(PENDING); + extractionService.startExtraction(ATT_123, documentInput, TENANT_1); - @Test - void startExtractionTransitionsStatusToFailedWhenProcessingThrows() { - doThrow(new RuntimeException("simulated failure")) - .when(documentAiProcessingService) - .processDocument(any(), any()); + ArgumentCaptor captor = forClass(CqnUpdate.class); + verify(persistenceService, times(2)).run(captor.capture()); + List updates = captor.getAllValues(); - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + ExtractionJob statusUpdate = + Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class); + assertThat(statusUpdate.getStatus()).isEqualTo(SUBMITTED.name()); - List updates = captureStatusUpdates(2); - assertStatusSequence(updates, PROCESSING, FAILED); + ExtractionJob jobUpdate = + Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class); + assertThat(jobUpdate.getDocumentAiJobId()).isEqualTo(DIE_JOB_ID); } @Test void startExtractionFailsWhenJobNotFound() { + mockInsertDatabaseCalls(); + mockSuccessfulProcessing(); Result emptyResult = mock(Result.class); when(emptyResult.single(ExtractionJob.class)).thenThrow(new RuntimeException("not found")); when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult); - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + extractionService.startExtraction(ATT_123, documentInput, TENANT_1); verify(persistenceService, never()).run(any(CqnUpdate.class)); } @Test void updateStatusWithSameStateDoesNotRunJobAgain() { - // Job is already PROCESSING — calling updateStatus(PROCESSING) should be a no-op - Result processingResult = jobWithStatus(PROCESSING); - when(persistenceService.run(any(CqnSelect.class))).thenReturn(processingResult); - doThrow(new RuntimeException("simulated failure")) - .when(documentAiProcessingService) - .processDocument(any(), any()); + // SELECT returns SUBMITTED — updateStatus(SUBMITTED) is a same-state no-op, no status UPDATE. + // updateDocumentAiJobId still fires, so exactly 1 UPDATE total (for the job ID, not status). + mockInsertDatabaseCalls(); + mockSuccessfulProcessing(); + mockStatusResult(SUBMITTED); - Assertions.assertThatNoException() - .isThrownBy( - () -> extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent)); + extractionService.startExtraction(ATT_123, documentInput, TENANT_1); - // PENDING→PROCESSING update happened before processDocument threw, - // but PROCESSING→FAILED is skipped because the job reads back as PROCESSING (same state) verify(persistenceService, times(1)).run(any(CqnUpdate.class)); } @Test - void markJobAsFailedIsSkippedWhenTransitionFromPendingToFailedIsInvalid() { - Result pendingResult = jobWithStatus(PENDING); - lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(pendingResult); + void invalidTransitionIsLoggedAndNoStatusUpdateOccurs() { + mockInsertDatabaseCalls(); + mockSuccessfulProcessing(); + mockStatusResult(COMPLETED); + extractionService.startExtraction(ATT_123, documentInput, TENANT_1); + verify(persistenceService, never()).run(any(CqnUpdate.class)); + } + + @Test + void markJobAsFailedSucceedsWhenTransitionFromPendingToFailedIsValid() { + mockAllDatabaseCalls(); + mockStatusResult(PENDING); doThrow(new RuntimeException("simulated failure")) .when(documentAiProcessingService) .processDocument(any(), any()); - - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); - + extractionService.startExtraction(ATT_123, documentInput, TENANT_1); verify(persistenceService, times(1)).run(any(CqnUpdate.class)); } - @Test - void invalidTransitionIsLoggedAndNoStatusUpdateOccurs() { - // COMPLETED has no valid outgoing transitions — PROCESSING update throws IllegalStateException - Result completedResult = jobWithStatus(COMPLETED); - lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(completedResult); + private void mockStatusResult(ExtractionStatus status) { + Result result = resultWithJobStatus(status); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); + } - extractionService.startExtraction(ATT_123, CNT_123, TENANT_1, mockContent); + private void mockSuccessfulProcessing() { + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + } - verify(persistenceService, never()).run(any(CqnUpdate.class)); + private void mockInsertDatabaseCalls() { + ExtractionJob createdJob = ExtractionJob.create(); + createdJob.setId("test-job-id"); + when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); + when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); + } + + private void mockAllDatabaseCalls() { + mockInsertDatabaseCalls(); + when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); } - private Result jobWithStatus(ExtractionStatus status) { + 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; } - - private List captureStatusUpdates(int expectedCount) { - ArgumentCaptor captor = forClass(CqnUpdate.class); - verify(persistenceService, times(expectedCount)).run(captor.capture()); - return captor.getAllValues(); - } - - private void assertStatusSequence( - List updates, ExtractionStatus first, ExtractionStatus second) { - Assertions.assertThat( - Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(first.name()); - Assertions.assertThat( - Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class).getStatus()) - .isEqualTo(second.name()); - } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java index 43bb19d..54b323d 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java @@ -11,8 +11,23 @@ class StatusTransitionValidatorTest { @Test - void pendingToProcessingIsValid() { - Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, PROCESSING)).isTrue(); + void pendingToSubmittedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, SUBMITTED)).isTrue(); + } + + @Test + void pendingToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, FAILED)).isTrue(); + } + + @Test + void submittedToProcessingIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, PROCESSING)).isTrue(); + } + + @Test + void submittedToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, FAILED)).isTrue(); } @Test From 81c816f42790008afa377ec2c027807b07b489d4 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:59:20 +0200 Subject: [PATCH 40/70] fix: review fixes --- .../cds/handlers/AttachmentEventHandler.java | 61 +++++++++---------- .../cds/service/ExtractionServiceImpl.java | 34 ++++++----- .../client/DefaultDocumentAiClient.java | 16 +++-- .../DocumentAiConnectivityException.java | 13 ++++ .../DocumentAiRequestException.java | 16 +++++ .../sap-document-ai/extraction-job.cds | 2 +- .../sap/cds/AttachmentEventHandlerTest.java | 1 - .../service/ExtractionServiceImplTest.java | 20 +++--- 8 files changed, 94 insertions(+), 69 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiConnectivityException.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiRequestException.java diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 567f5eb..7863645 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -130,29 +130,33 @@ private Optional getAttachment(CdsEntity attachmentEntity, String c contentId, attachmentEntity.getQualifiedName()); - List selectionResults = selectData(attachmentEntity, contentId); - - for (SelectionResult result : selectionResults) { - long rowCount = result.result().rowCount(); - - if (rowCount <= 0) { - logger.info( - "No attachment {} found in entity {}.", contentId, result.entity().getQualifiedName()); - continue; - } - - if (rowCount > 1) { - throw new IllegalStateException( - "More than one attachment with contentId %s.".formatted(contentId)); - } - - Attachments found = result.result().single(Attachments.class); - if (found != null) { - return Optional.of(found); - } - } - - return Optional.empty(); + return selectData(attachmentEntity, contentId).stream() + .filter( + result -> { + long rowCount = result.result().rowCount(); + if (rowCount <= 0) { + logger.info( + "No attachment {} found in entity {}.", + contentId, + result.entity().getQualifiedName()); + return false; + } + if (rowCount > 1) { + throw new IllegalStateException( + "More than one attachment with contentId %s.".formatted(contentId)); + } + return true; + }) + .findFirst() + .map( + result -> { + Attachments found = result.result().single(Attachments.class); + logger.debug( + "Found attachment {} in entity {}.", + found.getContentId(), + result.entity().getQualifiedName()); + return found; + }); } private List selectData(CdsEntity attachmentEntity, String contentId) { @@ -176,16 +180,7 @@ private Result readData(String contentId, CdsEntity entity) { .columns(Attachments.CONTENT_ID, Attachments.CONTENT) .where(e -> e.get(Attachments.CONTENT_ID).eq(contentId)); - Result result = persistenceService.run(select); - result - .streamOf(Attachments.class) - .forEach( - attachment -> - logger.debug( - "Found attachment {} in entity {}.", - attachment.getContentId(), - entity.getQualifiedName())); - return result; + return persistenceService.run(select); } private record SelectionResult(CdsEntity entity, Result result) {} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 3900394..2853bc7 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -46,8 +46,7 @@ public void startExtraction(String attachmentId, DocumentInput documentInput, St String jobId = createExtractionJob(attachmentId, tenantId); try { String documentAiJobId = documentAiProcessingService.processDocument(jobId, documentInput); - updateStatus(jobId, SUBMITTED); - updateDocumentAiJobId(jobId, documentAiJobId); + updateStatusAndSetDocumentAiJobId(jobId, SUBMITTED, documentAiJobId); // TODO: transition to PROCESSING and COMPLETED via async polling callback, not here // updateStatus(jobId, PROCESSING); @@ -88,6 +87,17 @@ private String createExtractionJob(String attachmentId, String tenantId) { } private void updateStatus(String jobId, ExtractionStatus status) { + updateExtractionJob(jobId, status, null); + } + + private void updateStatusAndSetDocumentAiJobId( + String jobId, ExtractionStatus status, String documentAiJobId) { + + updateExtractionJob(jobId, status, documentAiJobId); + } + + private void updateExtractionJob(String jobId, ExtractionStatus status, String documentAiJobId) { + Result current = persistenceService.run(Select.from(ExtractionJob_.class).byId(jobId)); ExtractionStatus currentStatus = ExtractionStatus.valueOf(current.single(ExtractionJob.class).getStatus()); @@ -107,22 +117,16 @@ private void updateStatus(String jobId, ExtractionStatus status) { ExtractionJob extractionJob = ExtractionJob.create(); extractionJob.setStatus(status.name()); - persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); - logger.info( - "[sap-document-ai] ExtractionJob jobId={} status updated from {} to {}", - jobId, - currentStatus, - status); - } - - private void updateDocumentAiJobId(String jobId, String documentAiJobId) { - ExtractionJob extractionJob = ExtractionJob.create(); - extractionJob.setDocumentAiJobId(documentAiJobId); + if (documentAiJobId != null) { + extractionJob.setDocumentAiJobId(documentAiJobId); + } persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); logger.info( - "[sap-document-ai] ExtractionJob jobId={} has been updated with documentAiJobId now={}", + "[sap-document-ai] ExtractionJob jobId={} status updated from {} to {}{}", jobId, - documentAiJobId); + currentStatus, + status, + documentAiJobId != null ? " with documentAiJobId=" + documentAiJobId : ""); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java index e72c497..35392e9 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java @@ -6,6 +6,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.service.exceptions.DocumentAiConnectivityException; +import com.sap.cds.service.exceptions.DocumentAiRequestException; import com.sap.cds.service.model.DocumentInput; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; import java.io.ByteArrayInputStream; @@ -26,7 +28,7 @@ public class DefaultDocumentAiClient implements DocumentAiClient { private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiClient.class); - private static final String DOCUMENT_AI_API_PATH = "/document-information-extraction/v1"; + private static final String DOCUMENT_AI_API_PATH = "document-information-extraction/v1"; private static final ObjectMapper objectMapper = new ObjectMapper(); private final HttpDestination destination; private final HttpClient httpClient; @@ -45,13 +47,15 @@ public String submitDocument(DocumentInput documentInput) { } private URI buildSubmitUri() { - return destination.getUri().resolve(DOCUMENT_AI_API_PATH + "/document/jobs"); + String base = destination.getUri().toString(); + String path = base.endsWith("/") ? base : base + "/"; + return URI.create(path).resolve(DOCUMENT_AI_API_PATH + "/document/jobs"); } private HttpPost buildSubmitRequest(DocumentInput documentInput, URI submitUri) { InputStream content = documentInput.content(); logger.info( - "[sap-document-ai] ----------Submitting document to DIE at url={} with content={}", + "[sap-document-ai] Submitting document to DIE at url={} with content={}", submitUri, content); @@ -94,13 +98,13 @@ private String executeRequest(HttpPost request, URI submitUri) { int statusCode = response.getStatusLine().getStatusCode(); if (statusCode < 200 || statusCode >= 300) { - throw new RuntimeException("DIE request failed. Status=" + statusCode + ", body=" + body); + throw new DocumentAiRequestException(statusCode, body); } return body; - } catch (Exception e) { - throw new RuntimeException("Failed to submit document to DIE at " + submitUri, e); + } catch (IOException e) { + throw new DocumentAiConnectivityException(submitUri.toString(), e); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiConnectivityException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiConnectivityException.java new file mode 100644 index 0000000..e3cfc5e --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiConnectivityException.java @@ -0,0 +1,13 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.exceptions; + +import java.io.IOException; + +public class DocumentAiConnectivityException extends RuntimeException { + + public DocumentAiConnectivityException(String url, IOException cause) { + super("Failed to connect to DIE at " + url, cause); + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiRequestException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiRequestException.java new file mode 100644 index 0000000..940f1a6 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiRequestException.java @@ -0,0 +1,16 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.exceptions; + +public class DocumentAiRequestException extends RuntimeException { + + public final int statusCode; + public final String responseBody; + + public DocumentAiRequestException(int statusCode, String responseBody) { + super("DIE request failed. Status=" + statusCode + ", body=" + responseBody); + this.statusCode = statusCode; + this.responseBody = responseBody; + } +} diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index a9af2f2..6b94260 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -10,5 +10,5 @@ entity ExtractionJob : cuid, managed { attachmentId : String; status : String; tenantId:String; - DocumentAiJobId : String; + documentAiJobId : String; } diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index 5434d2c..c65e064 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -173,7 +173,6 @@ private Result createAttachmentResult(InputStream content) { Result result = mock(Result.class); doReturn(1L).when(result).rowCount(); doReturn(attachment).when(result).single(Attachments.class); - doReturn(java.util.stream.Stream.of(attachment)).when(result).streamOf(Attachments.class); return result; } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 576e486..5ef3647 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -18,7 +18,6 @@ import com.sap.cds.services.persistence.PersistenceService; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -87,16 +86,12 @@ void startExtractionStoresDocumentAiJobIdAndUpdatesStatusToSubmitted() { extractionService.startExtraction(ATT_123, documentInput, TENANT_1); ArgumentCaptor captor = forClass(CqnUpdate.class); - verify(persistenceService, times(2)).run(captor.capture()); - List updates = captor.getAllValues(); + verify(persistenceService, times(1)).run(captor.capture()); - ExtractionJob statusUpdate = - Struct.access(updates.get(0).entries().get(0)).as(ExtractionJob.class); - assertThat(statusUpdate.getStatus()).isEqualTo(SUBMITTED.name()); - - ExtractionJob jobUpdate = - Struct.access(updates.get(1).entries().get(0)).as(ExtractionJob.class); - assertThat(jobUpdate.getDocumentAiJobId()).isEqualTo(DIE_JOB_ID); + ExtractionJob update = + Struct.access(captor.getValue().entries().get(0)).as(ExtractionJob.class); + assertThat(update.getStatus()).isEqualTo(SUBMITTED.name()); + assertThat(update.getDocumentAiJobId()).isEqualTo(DIE_JOB_ID); } @Test @@ -114,15 +109,14 @@ void startExtractionFailsWhenJobNotFound() { @Test void updateStatusWithSameStateDoesNotRunJobAgain() { - // SELECT returns SUBMITTED — updateStatus(SUBMITTED) is a same-state no-op, no status UPDATE. - // updateDocumentAiJobId still fires, so exactly 1 UPDATE total (for the job ID, not status). + // SELECT returns SUBMITTED — same-state check short-circuits before the UPDATE. mockInsertDatabaseCalls(); mockSuccessfulProcessing(); mockStatusResult(SUBMITTED); extractionService.startExtraction(ATT_123, documentInput, TENANT_1); - verify(persistenceService, times(1)).run(any(CqnUpdate.class)); + verify(persistenceService, never()).run(any(CqnUpdate.class)); } @Test From b27bfc56ccd7eaa43f0ed7f63f7dac821c44e3a9 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:43:59 +0200 Subject: [PATCH 41/70] refactor: use outbox --- sap-document-ai/pom.xml | 5 + .../AttachmentEventHandlerRegistration.java | 19 +- .../cds/handlers/AttachmentEventHandler.java | 158 ++--------------- .../sap/cds/service/ExtractionService.java | 8 +- .../cds/service/ExtractionServiceImpl.java | 123 ++++++++++++- .../service/StartExtractionEventContext.java | 39 ++++ .../sap/cds/AttachmentEventHandlerTest.java | 167 +++--------------- .../service/ExtractionServiceImplTest.java | 152 +++++++++++----- 8 files changed, 334 insertions(+), 337 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/StartExtractionEventContext.java diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index f083272..07e57e2 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -84,6 +84,11 @@ 3.26.3 test + + com.sap.cds + cds-services-impl + test + diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index 5aacb98..ffbf1fa 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -12,6 +12,7 @@ import com.sap.cds.service.documentai.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; @@ -29,6 +30,8 @@ public class AttachmentEventHandlerRegistration implements CdsRuntimeConfigurati private static final Logger logger = LoggerFactory.getLogger(AttachmentEventHandlerRegistration.class); + private ExtractionServiceImpl extractionService; + static { OAuth2ServiceBindingDestinationLoader.registerPropertySupplier( options -> @@ -38,6 +41,12 @@ public class AttachmentEventHandlerRegistration implements CdsRuntimeConfigurati DefaultOAuth2PropertySupplier::new); } + @Override + public void services(CdsRuntimeConfigurer configurer) { + extractionService = new ExtractionServiceImpl(); + configurer.service(extractionService); + } + @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); @@ -52,11 +61,15 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { DocumentAiProcessingService documentAiProcessingService = new DefaultDocumentAiProcessingService(documentAiClient); - ExtractionService extractionService = - new ExtractionServiceImpl(persistenceService, documentAiProcessingService); + extractionService.init(persistenceService, documentAiProcessingService); + + OutboxService outboxService = + serviceCatalog.getService(OutboxService.class, OutboxService.INMEMORY_NAME); + + ExtractionService outboxedExtractionService = outboxService.outboxed(extractionService); // register event handler with CAP runtime - configurer.eventHandler(new AttachmentEventHandler(extractionService, persistenceService)); + configurer.eventHandler(new AttachmentEventHandler(outboxedExtractionService)); } static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java index 7863645..06906e2 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java @@ -3,28 +3,15 @@ */ package com.sap.cds.handlers; -import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; -import com.sap.cds.ql.Select; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.reflect.CdsElementNotFoundException; -import com.sap.cds.reflect.CdsEntity; import com.sap.cds.service.ExtractionService; -import com.sap.cds.service.model.DocumentInput; -import com.sap.cds.services.changeset.ChangeSetListener; -import com.sap.cds.services.draft.Drafts; +import com.sap.cds.service.StartExtractionEventContext; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.persistence.PersistenceService; -import com.sap.cds.services.request.RequestContext; -import com.sap.cds.services.runtime.CdsRuntime; -import java.io.InputStream; -import java.util.*; -import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,154 +21,35 @@ public class AttachmentEventHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(AttachmentEventHandler.class); private final ExtractionService extractionService; - private final PersistenceService persistenceService; - public AttachmentEventHandler( - ExtractionService extractionService, PersistenceService persistenceService) { + public AttachmentEventHandler(ExtractionService extractionService) { this.extractionService = extractionService; - this.persistenceService = persistenceService; } @After(event = AttachmentService.EVENT_CREATE_ATTACHMENT) public void afterCreateAttachment(AttachmentCreateEventContext context) { String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); - String tenantId = context.getUserInfo().getTenant(); - String contentId = context.getContentId(); - CdsRuntime cdsRuntime = context.getCdsRuntime(); if (attachmentId == null) { logger.warn("[sap-document-ai] attachmentId is null, skipping extraction"); return; } - MediaData contextData = context.getData(); - CdsEntity attachmentEntity = context.getAttachmentEntity(); + MediaData data = context.getData(); - context - .getChangeSetContext() - .register( - new ChangeSetListener() { - - @Override - public void afterClose(boolean completed) { - logger.info("[sap-document-ai] afterClose fired, completed={}", completed); - if (!completed) return; - - cdsRuntime - .requestContext() - .run( - (Consumer) - requestCtx -> - cdsRuntime - .changeSetContext() - .run( - changeSetCtx -> { - String fileName = contextData.getFileName(); - String mimeType = contextData.getMimeType(); - triggerExtraction( - attachmentEntity, - contentId, - attachmentId, - tenantId, - fileName, - mimeType); - })); - } - }); - } - - private void triggerExtraction( - CdsEntity attachmentEntity, - String contentId, - String attachmentId, - String tenantId, - String fileName, - String mimeType) { - Optional row = getAttachment(attachmentEntity, contentId); - - if (row.isEmpty()) { - logger.warn("[sap-document-ai] No attachment found for contentId={}, skipping", contentId); - return; - } - - Attachments attachment = row.get(); - - InputStream attachmentContent = attachment.getContent(); - - if (attachmentContent == null) { - logger.warn("[sap-document-ai] Content is null for contentId={}, skipping", contentId); - return; - } - - DocumentInput documentInput = - new DocumentInput(fileName, contentId, mimeType, attachmentContent); + StartExtractionEventContext eventContext = StartExtractionEventContext.create(); + eventContext.setAttachmentId(attachmentId); + eventContext.setContentId(context.getContentId()); + eventContext.setTenantId(context.getUserInfo().getTenant()); + eventContext.setFileName(data.getFileName()); + eventContext.setMimeType(data.getMimeType()); + eventContext.setAttachmentEntityName(context.getAttachmentEntity().getQualifiedName()); logger.info( - "[sap-document-ai] Triggering extraction for attachmentId={}, contentId={}", + "[sap-document-ai] Queuing extraction for attachmentId={}, contentId={}", attachmentId, - contentId); + context.getContentId()); - extractionService.startExtraction(attachmentId, documentInput, tenantId); + extractionService.emit(eventContext); } - - private Optional getAttachment(CdsEntity attachmentEntity, String contentId) { - logger.info( - "Started finding attachment {} of entity {}.", - contentId, - attachmentEntity.getQualifiedName()); - - return selectData(attachmentEntity, contentId).stream() - .filter( - result -> { - long rowCount = result.result().rowCount(); - if (rowCount <= 0) { - logger.info( - "No attachment {} found in entity {}.", - contentId, - result.entity().getQualifiedName()); - return false; - } - if (rowCount > 1) { - throw new IllegalStateException( - "More than one attachment with contentId %s.".formatted(contentId)); - } - return true; - }) - .findFirst() - .map( - result -> { - Attachments found = result.result().single(Attachments.class); - logger.debug( - "Found attachment {} in entity {}.", - found.getContentId(), - result.entity().getQualifiedName()); - return found; - }); - } - - private List selectData(CdsEntity attachmentEntity, String contentId) { - List result = new ArrayList<>(); - try { - CdsEntity entity = (CdsEntity) attachmentEntity.getTargetOf(Drafts.SIBLING_ENTITY); - Result selectionResult = readData(contentId, entity); - result.add(new SelectionResult(entity, selectionResult)); - } catch (CdsElementNotFoundException ignored) { - // no sibling found nothing to select - } - Result selectionResult = readData(contentId, attachmentEntity); - result.add(new SelectionResult(attachmentEntity, selectionResult)); - - return result; - } - - private Result readData(String contentId, CdsEntity entity) { - CqnSelect select = - Select.from(entity) - .columns(Attachments.CONTENT_ID, Attachments.CONTENT) - .where(e -> e.get(Attachments.CONTENT_ID).eq(contentId)); - - return persistenceService.run(select); - } - - private record SelectionResult(CdsEntity entity, Result result) {} } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index a8e54fa..5488cff 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -3,9 +3,11 @@ */ package com.sap.cds.service; -import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.services.Service; -public interface ExtractionService { +public interface ExtractionService extends Service { - void startExtraction(String attachmentId, DocumentInput documentInput, String tenantId); + String NAME = "ExtractionService"; + + String EVENT_START_EXTRACTION = "startExtraction"; } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 2853bc7..bc2abb8 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -6,43 +6,93 @@ import static com.sap.cds.service.ExtractionStatus.*; import com.sap.cds.Result; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; 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.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsElementNotFoundException; +import com.sap.cds.reflect.CdsEntity; import com.sap.cds.service.exceptions.IllegalStatusTransitionException; import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.services.ServiceDelegator; +import com.sap.cds.services.draft.Drafts; +import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.persistence.PersistenceService; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class ExtractionServiceImpl implements ExtractionService { +public class ExtractionServiceImpl extends ServiceDelegator implements ExtractionService { private static final Logger logger = LoggerFactory.getLogger(ExtractionServiceImpl.class); - private final PersistenceService persistenceService; - private final DocumentAiProcessingService documentAiProcessingService; + private PersistenceService persistenceService; + private DocumentAiProcessingService documentAiProcessingService; - public ExtractionServiceImpl( + public ExtractionServiceImpl() { + super(NAME); + } + + public void init( PersistenceService persistenceService, DocumentAiProcessingService documentAiProcessingService) { this.persistenceService = persistenceService; this.documentAiProcessingService = documentAiProcessingService; } - @Override - public void startExtraction(String attachmentId, DocumentInput documentInput, String tenantId) { + @On(event = EVENT_START_EXTRACTION) + public void onStartExtraction(StartExtractionEventContext context) { + context.setCompleted(); + + if (!documentAiProcessingService.isAvailable()) { + logger.warn("[sap-document-ai] Document AI client is not available, skipping submission"); + return; + } + + String attachmentId = context.getAttachmentId(); + String tenantId = context.getTenantId(); + String fileName = context.getFileName(); + String contentId = context.getContentId(); + String mimeType = context.getMimeType(); + logger.info( "[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); - if (!documentAiProcessingService.isAvailable()) { - logger.warn("[sap-document-ai] Document AI client is not available, skipping submission"); + CdsEntity attachmentEntity = + context.getCdsRuntime().getCdsModel().getEntity(context.getAttachmentEntityName()); + + Optional row = getAttachment(attachmentEntity, contentId); + + if (row.isEmpty()) { + logger.warn("[sap-document-ai] No attachment found for contentId={}, skipping", contentId); + return; + } + + Attachments attachment = row.get(); + + InputStream attachmentContent = attachment.getContent(); + + if (attachmentContent == null) { + logger.warn("[sap-document-ai] Content is null for contentId={}, skipping", contentId); return; } + DocumentInput documentInput = + new DocumentInput(fileName, contentId, mimeType, attachmentContent); + + logger.info( + "[sap-document-ai] Triggering extraction for attachmentId={}, contentId={}", + attachmentId, + contentId); + String jobId = createExtractionJob(attachmentId, tenantId); try { String documentAiJobId = documentAiProcessingService.processDocument(jobId, documentInput); @@ -129,4 +179,61 @@ private void updateExtractionJob(String jobId, ExtractionStatus status, String d status, documentAiJobId != null ? " with documentAiJobId=" + documentAiJobId : ""); } + + private Optional getAttachment(CdsEntity attachmentEntity, String contentId) { + logger.info( + "Started finding attachment {} of entity {}.", + contentId, + attachmentEntity.getQualifiedName()); + + return selectData(attachmentEntity, contentId).stream() + .filter( + result -> { + long rowCount = result.result().rowCount(); + if (rowCount <= 0) { + logger.info( + "No attachment {} found in entity {}.", + contentId, + result.entity().getQualifiedName()); + return false; + } + if (rowCount > 1) { + throw new IllegalStateException( + "More than one attachment with contentId %s.".formatted(contentId)); + } + return true; + }) + .findFirst() + .map( + r -> { + Attachments found = r.result().single(Attachments.class); + logger.debug( + "Found attachment {} in entity {}.", + found.getContentId(), + r.entity().getQualifiedName()); + return found; + }); + } + + private List selectData(CdsEntity attachmentEntity, String contentId) { + List result = new ArrayList<>(); + try { + CdsEntity sibling = attachmentEntity.getTargetOf(Drafts.SIBLING_ENTITY); + result.add(new SelectionResult(sibling, readData(contentId, sibling))); + } catch (CdsElementNotFoundException ignored) { + // no draft sibling — nothing to select + } + result.add(new SelectionResult(attachmentEntity, readData(contentId, attachmentEntity))); + return result; + } + + private Result readData(String contentId, CdsEntity entity) { + CqnSelect select = + Select.from(entity) + .columns(Attachments.CONTENT_ID, Attachments.CONTENT) + .where(e -> e.get(Attachments.CONTENT_ID).eq(contentId)); + return persistenceService.run(select); + } + + private record SelectionResult(CdsEntity entity, Result result) {} } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/StartExtractionEventContext.java b/sap-document-ai/src/main/java/com/sap/cds/service/StartExtractionEventContext.java new file mode 100644 index 0000000..8ceb562 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/StartExtractionEventContext.java @@ -0,0 +1,39 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; + +@EventName(ExtractionService.EVENT_START_EXTRACTION) +public interface StartExtractionEventContext extends EventContext { + + static StartExtractionEventContext create() { + return EventContext.create(StartExtractionEventContext.class, null); + } + + String getAttachmentId(); + + void setAttachmentId(String attachmentId); + + String getContentId(); + + void setContentId(String contentId); + + String getTenantId(); + + void setTenantId(String tenantId); + + String getFileName(); + + void setFileName(String fileName); + + String getMimeType(); + + void setMimeType(String mimeType); + + String getAttachmentEntityName(); + + void setAttachmentEntityName(String attachmentEntityName); +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java index c65e064..9d4c565 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java @@ -3,6 +3,7 @@ */ package com.sap.cds; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -10,24 +11,11 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.handlers.AttachmentEventHandler; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.reflect.CdsDefinition; -import com.sap.cds.reflect.CdsElementNotFoundException; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.service.ExtractionService; -import com.sap.cds.service.model.DocumentInput; -import com.sap.cds.services.changeset.ChangeSetContext; -import com.sap.cds.services.changeset.ChangeSetListener; -import com.sap.cds.services.persistence.PersistenceService; -import com.sap.cds.services.request.RequestContext; +import com.sap.cds.service.StartExtractionEventContext; import com.sap.cds.services.request.UserInfo; -import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cds.services.runtime.ChangeSetContextRunner; -import com.sap.cds.services.runtime.RequestContextRunner; -import java.io.ByteArrayInputStream; -import java.io.InputStream; import java.util.Map; -import java.util.function.Consumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,153 +29,56 @@ class AttachmentEventHandlerTest { private static final String ATTACHMENT_ID = "test-attachment-id"; private static final String CONTENT_ID = "test-content-id"; private static final String TENANT_ID = "test-tenant"; - private static final String TEST_ENTITY = "test.Entity"; + private static final String FILE_NAME = "dummy_invoice.pdf"; + private static final String MIME_TYPE = "application/pdf"; + private static final String ENTITY_NAME = "test.Entity"; @Mock ExtractionService extractionService; - @Mock PersistenceService persistenceService; @Mock AttachmentCreateEventContext context; @Mock UserInfo userInfo; @Mock MediaData mediaData; - @Mock DocumentInput documentInput; @Mock CdsEntity attachmentEntity; - @Mock CdsRuntime cdsRuntime; - @Mock RequestContextRunner requestContextRunner; - @Mock ChangeSetContextRunner changeSetContextRunner; - @Mock ChangeSetContext changeSetContext; AttachmentEventHandler handler; @BeforeEach void setUp() { - handler = new AttachmentEventHandler(extractionService, persistenceService); - - when(context.getAttachmentIds()).thenReturn(Map.of("ID", ATTACHMENT_ID)); - when(context.getContentId()).thenReturn(CONTENT_ID); - when(context.getUserInfo()).thenReturn(userInfo); - when(context.getCdsRuntime()).thenReturn(cdsRuntime); - - // no draft sibling for this entity - lenient() - .doThrow(new CdsElementNotFoundException("no sibling", mock(CdsDefinition.class))) - .when(attachmentEntity) - .getTargetOf(any()); - - // wire runtime to execute lambdas inline (synchronously) - lenient().when(cdsRuntime.requestContext()).thenReturn(requestContextRunner); - lenient() - .doAnswer( - inv -> { - inv.getArgument(0, Consumer.class).accept(mock(RequestContext.class)); - return null; - }) - .when(requestContextRunner) - .run(any(Consumer.class)); - lenient().when(cdsRuntime.changeSetContext()).thenReturn(changeSetContextRunner); - lenient() - .doAnswer( - inv -> { - inv.getArgument(0, Consumer.class).accept(mock(ChangeSetContext.class)); - return null; - }) - .when(changeSetContextRunner) - .run(any(Consumer.class)); - } - - @Test - void shouldStartExtractionAfterSuccessfulCommit() { - mockAttachmentContext(); - mockExtractionContext(); - when(context.getChangeSetContext()).thenReturn(changeSetContext); - InputStream content = new ByteArrayInputStream("pdf-bytes".getBytes()); - mockAttachmentLookup(createAttachmentResult(content)); - - commitChangeSet(); - - verify(extractionService) - .startExtraction(eq(ATTACHMENT_ID), any(DocumentInput.class), eq(TENANT_ID)); + handler = new AttachmentEventHandler(extractionService); + when(context.getAttachmentIds()).thenReturn(Map.of(Attachments.ID, ATTACHMENT_ID)); } @Test - void shouldNotStartExtractionWhenChangeSetIsRolledBack() { - when(context.getChangeSetContext()).thenReturn(changeSetContext); - rollbackChangeSet(); - verify(extractionService, never()).startExtraction(any(), any(), any()); - } - - @Test - void shouldNotStartExtractionWhenAttachmentRecordDoesNotExist() { - Result emptyResult = mock(Result.class); - when(emptyResult.rowCount()).thenReturn(0L); - mockAttachmentContext(); - mockExtractionContext(); - when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult); - when(attachmentEntity.getQualifiedName()).thenReturn(TEST_ENTITY); - when(context.getChangeSetContext()).thenReturn(changeSetContext); - - commitChangeSet(); - - verify(extractionService, never()).startExtraction(any(), any(), any()); - } - - @Test - void shouldNotStartExtractionWhenAttachmentContentIsMissing() { - mockAttachmentContext(); - mockExtractionContext(); - mockAttachmentLookup(createAttachmentResult(null)); - when(context.getChangeSetContext()).thenReturn(changeSetContext); - commitChangeSet(); - verify(extractionService, never()).startExtraction(any(), any(), any()); - } - - @Test - void shouldNotRegisterChangeSetListenerWhenAttachmentIdIsMissing() { - when(context.getAttachmentIds()).thenReturn(Map.of()); - handler.afterCreateAttachment(context); - verify(changeSetContext, never()).register(any()); - verify(extractionService, never()).startExtraction(any(), any(), any()); - } - - private void mockAttachmentContext() { + void shouldEmitStartExtractionEvent() { + when(context.getContentId()).thenReturn(CONTENT_ID); + when(context.getUserInfo()).thenReturn(userInfo); when(context.getData()).thenReturn(mediaData); when(context.getAttachmentEntity()).thenReturn(attachmentEntity); - } - - private void mockExtractionContext() { + when(attachmentEntity.getQualifiedName()).thenReturn(ENTITY_NAME); when(userInfo.getTenant()).thenReturn(TENANT_ID); - when(mediaData.getFileName()).thenReturn("dummy_invoice.pdf"); - when(mediaData.getMimeType()).thenReturn("application/pdf"); - } + when(mediaData.getFileName()).thenReturn(FILE_NAME); + when(mediaData.getMimeType()).thenReturn(MIME_TYPE); - private ChangeSetListener captureRegisteredChangeSetListener() { handler.afterCreateAttachment(context); - ArgumentCaptor captor = ArgumentCaptor.forClass(ChangeSetListener.class); - verify(changeSetContext).register(captor.capture()); - return captor.getValue(); - } - private Result createAttachmentResult(InputStream content) { - Attachments attachment = Attachments.create(); - attachment.setContentId(CONTENT_ID); - attachment.setContent(content); - - Result result = mock(Result.class); - doReturn(1L).when(result).rowCount(); - doReturn(attachment).when(result).single(Attachments.class); - return result; + ArgumentCaptor captor = + ArgumentCaptor.forClass(StartExtractionEventContext.class); + verify(extractionService).emit(captor.capture()); + + StartExtractionEventContext emitted = captor.getValue(); + assertThat(emitted.getAttachmentId()).isEqualTo(ATTACHMENT_ID); + assertThat(emitted.getContentId()).isEqualTo(CONTENT_ID); + assertThat(emitted.getTenantId()).isEqualTo(TENANT_ID); + assertThat(emitted.getFileName()).isEqualTo(FILE_NAME); + assertThat(emitted.getMimeType()).isEqualTo(MIME_TYPE); + assertThat(emitted.getAttachmentEntityName()).isEqualTo(ENTITY_NAME); } - private void rollbackChangeSet() { - ChangeSetListener listener = captureRegisteredChangeSetListener(); - listener.afterClose(false); - } + @Test + void shouldNotEmitWhenAttachmentIdIsMissing() { + when(context.getAttachmentIds()).thenReturn(Map.of()); - private void commitChangeSet() { - ChangeSetListener listener = captureRegisteredChangeSetListener(); - listener.afterClose(true); - } + handler.afterCreateAttachment(context); - private void mockAttachmentLookup(Result result) { - when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); - when(attachmentEntity.getQualifiedName()).thenReturn(TEST_ENTITY); + verify(extractionService, never()).emit(any()); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 5ef3647..29b57d2 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -10,13 +10,18 @@ import com.sap.cds.Result; import com.sap.cds.Struct; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; 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.service.model.DocumentInput; +import com.sap.cds.reflect.CdsElementNotFoundException; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -33,44 +38,43 @@ class ExtractionServiceImplTest { static final String ATT_123 = "att-123"; static final String CNT_123 = "cnt-123"; static final String DIE_JOB_ID = "die-job-123"; - public static final String TEST_PDF = "test.pdf"; - public static final String CONTENT_TYPE = "application/pdf"; - public static final String TEST_CONTENT = "test-content"; + static final String TEST_PDF = "test.pdf"; + static final String CONTENT_TYPE = "application/pdf"; + static final String TEST_CONTENT = "test-content"; + static final String ENTITY_NAME = "test.Attachments"; @Mock PersistenceService persistenceService; @Mock DocumentAiProcessingService documentAiProcessingService; @Mock Result insertResult; + @Mock CdsRuntime cdsRuntime; + @Mock CdsModel cdsModel; + @Mock CdsEntity cdsEntity; - DocumentInput documentInput; ExtractionServiceImpl extractionService; @BeforeEach void setUp() { when(documentAiProcessingService.isAvailable()).thenReturn(true); - documentInput = - new DocumentInput( - TEST_PDF, - CNT_123, - CONTENT_TYPE, - new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8))); - extractionService = new ExtractionServiceImpl(persistenceService, documentAiProcessingService); + extractionService = new ExtractionServiceImpl(); + extractionService.init(persistenceService, documentAiProcessingService); } @Test void startExtractionDoesNothingWhenServiceUnavailable() { when(documentAiProcessingService.isAvailable()).thenReturn(false); - extractionService.startExtraction(ATT_123, documentInput, TENANT_1); + extractionService.onStartExtraction(eventContext()); verify(persistenceService, never()).run(any(CqnInsert.class)); } @Test void startExtractionCreatesOneJobWithCorrectFields() { + mockContentLookup(contentStream()); mockAllDatabaseCalls(); mockSuccessfulProcessing(); - extractionService.startExtraction(ATT_123, documentInput, TENANT_1); + extractionService.onStartExtraction(eventContext()); ArgumentCaptor insertCaptor = forClass(CqnInsert.class); - verify(persistenceService, times(1)).run(insertCaptor.capture()); + verify(persistenceService, atLeastOnce()).run(insertCaptor.capture()); ExtractionJob inserted = Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); assertThat(inserted.getAttachmentId()).isEqualTo(ATT_123); @@ -80,10 +84,11 @@ void startExtractionCreatesOneJobWithCorrectFields() { @Test void startExtractionStoresDocumentAiJobIdAndUpdatesStatusToSubmitted() { + Result statusResult = resultWithJobStatus(PENDING); + mockContentThenStatus(contentStream(), statusResult); mockAllDatabaseCalls(); mockSuccessfulProcessing(); - mockStatusResult(PENDING); - extractionService.startExtraction(ATT_123, documentInput, TENANT_1); + extractionService.onStartExtraction(eventContext()); ArgumentCaptor captor = forClass(CqnUpdate.class); verify(persistenceService, times(1)).run(captor.capture()); @@ -95,69 +100,136 @@ void startExtractionStoresDocumentAiJobIdAndUpdatesStatusToSubmitted() { } @Test - void startExtractionFailsWhenJobNotFound() { - mockInsertDatabaseCalls(); - mockSuccessfulProcessing(); - Result emptyResult = mock(Result.class); - when(emptyResult.single(ExtractionJob.class)).thenThrow(new RuntimeException("not found")); - when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult); - - extractionService.startExtraction(ATT_123, documentInput, TENANT_1); + void startExtractionSkipsWhenAttachmentNotFound() { + mockContentLookup(null); + extractionService.onStartExtraction(eventContext()); + verify(persistenceService, never()).run(any(CqnInsert.class)); + } - verify(persistenceService, never()).run(any(CqnUpdate.class)); + @Test + void startExtractionSkipsWhenContentStreamIsNull() { + mockContentLookupWithNullStream(); + extractionService.onStartExtraction(eventContext()); + verify(persistenceService, never()).run(any(CqnInsert.class)); } @Test void updateStatusWithSameStateDoesNotRunJobAgain() { - // SELECT returns SUBMITTED — same-state check short-circuits before the UPDATE. + Result statusResult = resultWithJobStatus(SUBMITTED); + mockContentThenStatus(contentStream(), statusResult); mockInsertDatabaseCalls(); mockSuccessfulProcessing(); - mockStatusResult(SUBMITTED); - extractionService.startExtraction(ATT_123, documentInput, TENANT_1); + extractionService.onStartExtraction(eventContext()); verify(persistenceService, never()).run(any(CqnUpdate.class)); } @Test void invalidTransitionIsLoggedAndNoStatusUpdateOccurs() { + Result statusResult = resultWithJobStatus(COMPLETED); + mockContentThenStatus(contentStream(), statusResult); mockInsertDatabaseCalls(); mockSuccessfulProcessing(); - mockStatusResult(COMPLETED); - extractionService.startExtraction(ATT_123, documentInput, TENANT_1); + extractionService.onStartExtraction(eventContext()); verify(persistenceService, never()).run(any(CqnUpdate.class)); } @Test void markJobAsFailedSucceedsWhenTransitionFromPendingToFailedIsValid() { + Result statusResult = resultWithJobStatus(PENDING); + mockContentThenStatus(contentStream(), statusResult); mockAllDatabaseCalls(); - mockStatusResult(PENDING); doThrow(new RuntimeException("simulated failure")) .when(documentAiProcessingService) .processDocument(any(), any()); - extractionService.startExtraction(ATT_123, documentInput, TENANT_1); + extractionService.onStartExtraction(eventContext()); verify(persistenceService, times(1)).run(any(CqnUpdate.class)); } - private void mockStatusResult(ExtractionStatus status) { - Result result = resultWithJobStatus(status); - when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); + private StartExtractionEventContext eventContext() { + StartExtractionEventContext ctx = mock(StartExtractionEventContext.class); + lenient().when(ctx.getAttachmentId()).thenReturn(ATT_123); + lenient().when(ctx.getTenantId()).thenReturn(TENANT_1); + lenient().when(ctx.getContentId()).thenReturn(CNT_123); + lenient().when(ctx.getFileName()).thenReturn(TEST_PDF); + lenient().when(ctx.getMimeType()).thenReturn(CONTENT_TYPE); + lenient().when(ctx.getAttachmentEntityName()).thenReturn(ENTITY_NAME); + lenient().when(ctx.getCdsRuntime()).thenReturn(cdsRuntime); + return ctx; + } + + private InputStream contentStream() { + return new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8)); + } + + private void mockEntityLookup() { + when(cdsRuntime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getEntity(ENTITY_NAME)).thenReturn(cdsEntity); + when(cdsEntity.getQualifiedName()).thenReturn(ENTITY_NAME); + doThrow(CdsElementNotFoundException.class).when(cdsEntity).getTargetOf(any()); + } + + private void mockContentThenStatus(InputStream content, Result statusResult) { + mockEntityLookup(); + Attachments attachment = Attachments.create(); + attachment.setContentId(CNT_123); + attachment.setContent(content); + + Result contentResult = mock(Result.class); + lenient().when(contentResult.rowCount()).thenReturn(1L); + lenient().when(contentResult.single(Attachments.class)).thenReturn(attachment); + lenient().when(contentResult.first(Attachments.class)).thenReturn(Optional.of(attachment)); + + lenient() + .when(persistenceService.run(any(CqnSelect.class))) + .thenReturn(contentResult, statusResult); + } + + private void mockContentLookup(InputStream content) { + mockEntityLookup(); + Attachments attachment = Attachments.create(); + attachment.setContentId(CNT_123); + attachment.setContent(content); + + Result contentResult = mock(Result.class); + lenient().when(contentResult.rowCount()).thenReturn(content != null ? 1L : 0L); + lenient().when(contentResult.single(Attachments.class)).thenReturn(attachment); + lenient() + .when(contentResult.first(Attachments.class)) + .thenReturn(content != null ? Optional.of(attachment) : Optional.empty()); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(contentResult); + } + + private void mockContentLookupWithNullStream() { + mockEntityLookup(); + Attachments attachment = Attachments.create(); + attachment.setContentId(CNT_123); + attachment.setContent(null); + + Result contentResult = mock(Result.class); + lenient().when(contentResult.rowCount()).thenReturn(1L); + lenient().when(contentResult.single(Attachments.class)).thenReturn(attachment); + lenient().when(contentResult.first(Attachments.class)).thenReturn(Optional.of(attachment)); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(contentResult); } private void mockSuccessfulProcessing() { - when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + lenient() + .when(documentAiProcessingService.processDocument(any(), any())) + .thenReturn(DIE_JOB_ID); } private void mockInsertDatabaseCalls() { ExtractionJob createdJob = ExtractionJob.create(); createdJob.setId("test-job-id"); - when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); - when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); + lenient().when(insertResult.single(ExtractionJob.class)).thenReturn(createdJob); + lenient().when(persistenceService.run(any(CqnInsert.class))).thenReturn(insertResult); } private void mockAllDatabaseCalls() { mockInsertDatabaseCalls(); - when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); } private Result resultWithJobStatus(ExtractionStatus status) { From 73950e3525d6c6f374a898fe6b9e37acaf5d0e6d Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:07:15 +0200 Subject: [PATCH 42/70] feat: add standalone document upload entry point via Jobs media entity feat: make cds-feature-attachments dependency optional fix: review fixes refactor: rebase onto the parent branch test: add/update tests refactor: the workflow refactor: persistence to typed service fix: test file refactor: optimise exception files fix: review fixes by bot --- sap-document-ai/pom.xml | 1 - .../AttachmentEventHandlerRegistration.java | 38 ++- .../handlers/DocumentSubmissionHandler.java | 174 ++++++++++++ .../DefaultDocumentAiProcessingService.java | 4 +- .../sap/cds/service/ExtractionService.java | 9 + .../cds/service/ExtractionServiceImpl.java | 165 +++++++---- .../com/sap/cds/service/ExtractionStatus.java | 9 + .../client/DefaultDocumentAiClient.java | 36 +-- .../DocumentAiConnectivityException.java | 13 - .../exceptions/DocumentAiException.java | 40 +++ .../DocumentAiProcessingException.java | 11 - .../DocumentAiRequestException.java | 16 -- .../exceptions/SourceDocumentException.java | 27 ++ .../sap/cds/service/model/DocumentInput.java | 3 +- .../cds/service/model/ExtractionResult.java | 13 + .../cds/service/model/ExtractionSource.java | 22 ++ .../StatusTransitionValidator.java | 8 +- .../sap-document-ai/document-ai-service.cds | 17 ++ .../sap-document-ai/extraction-job.cds | 14 +- .../cds/com.sap.cds/sap-document-ai/index.cds | 1 + .../cds/DocumentSubmissionHandlerTest.java | 265 ++++++++++++++++++ ...ttachmentEventHandlerRegistrationTest.java | 145 +++++----- ...efaultDocumentAiProcessingServiceTest.java | 6 +- .../service/ExtractionServiceImplTest.java | 79 +++++- .../client/DefaultDocumentAiClientTest.java | 105 +++++++ .../service/exceptions/ExceptionsTest.java | 65 +++++ .../StatusTransitionValidatorTest.java | 2 +- 27 files changed, 1072 insertions(+), 216 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiConnectivityException.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiProcessingException.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiRequestException.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/SourceDocumentException.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionSource.java rename sap-document-ai/src/main/java/com/sap/cds/service/{ => utils}/StatusTransitionValidator.java (72%) create mode 100644 sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds create mode 100644 sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java rename sap-document-ai/src/test/java/com/sap/cds/service/{ => utils}/StatusTransitionValidatorTest.java (97%) diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 07e57e2..46d257e 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -180,7 +180,6 @@ **/feature/documentai/generated/** - **/service/documentai/client/DefaultDocumentAiClient.class diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index ffbf1fa..3102c65 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -3,7 +3,10 @@ */ package com.sap.cds.configuration; +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.DocumentAiService_; import com.sap.cds.handlers.AttachmentEventHandler; +import com.sap.cds.handlers.DocumentSubmissionHandler; import com.sap.cds.service.DefaultDocumentAiProcessingService; import com.sap.cds.service.DocumentAiProcessingService; import com.sap.cds.service.ExtractionService; @@ -55,6 +58,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { // framework-managed dependency PersistenceService persistenceService = serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + DocumentAiService documentAiService = + serviceCatalog.getService(DocumentAiService.class, DocumentAiService_.CDS_NAME); // internal DocumentAiClient documentAiClient = buildDocumentAi(runtime.getEnvironment()); @@ -64,12 +69,39 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { extractionService.init(persistenceService, documentAiProcessingService); OutboxService outboxService = - serviceCatalog.getService(OutboxService.class, OutboxService.INMEMORY_NAME); + serviceCatalog.getService(OutboxService.class, OutboxService.PERSISTENT_UNORDERED_NAME); - ExtractionService outboxedExtractionService = outboxService.outboxed(extractionService); + ExtractionService outboxedExtractionService; + + if (outboxService != null) { + outboxedExtractionService = outboxService.outboxed(extractionService); + } else { + outboxedExtractionService = extractionService; + logger.warn( + "OutboxService '{}' is not available. AttachmentService will not be outboxed.", + OutboxService.PERSISTENT_UNORDERED_NAME); + } // register event handler with CAP runtime - configurer.eventHandler(new AttachmentEventHandler(outboxedExtractionService)); + if (isAttachmentsPluginPresent()) { + configurer.eventHandler(new AttachmentEventHandler(outboxedExtractionService)); + logger.info( + "[sap-document-ai] cds-feature-attachments detected, attachment handler registered"); + } else { + logger.info( + "[sap-document-ai] cds-feature-attachments not found, attachment handler skipped"); + } + + configurer.eventHandler(new DocumentSubmissionHandler(extractionService, documentAiService)); + } + + private static boolean isAttachmentsPluginPresent() { + try { + Class.forName("com.sap.cds.feature.attachments.service.AttachmentService"); + return true; + } catch (ClassNotFoundException e) { + return false; + } } static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java new file mode 100644 index 0000000..409820f --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java @@ -0,0 +1,174 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.handlers; + +import static com.sap.cds.service.ExtractionService.EVENT_START_EXTRACTION; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.*; +import com.sap.cds.ql.Select; +import com.sap.cds.service.ExtractionService; +import com.sap.cds.service.exceptions.SourceDocumentException; +import com.sap.cds.service.model.ExtractionResult; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ServiceName(DocumentAiService_.CDS_NAME) +public class DocumentSubmissionHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(DocumentSubmissionHandler.class); + + private final ExtractionService extractionService; + private final DocumentAiService documentAiService; + + public DocumentSubmissionHandler( + ExtractionService extractionService, DocumentAiService documentAiService) { + this.extractionService = extractionService; + this.documentAiService = documentAiService; + } + + @On(event = EVENT_START_EXTRACTION) + public void onStartExtraction(StartExtractionContext context) { + context.setCompleted(); + + String sourceDocumentId = context.getSourceDocumentId(); + String tenantId = context.getUserInfo().getTenant(); + + logger.info( + "[sap-document-ai] startExtraction action called for sourceDocumentId={}", + sourceDocumentId); + + SourceDocument document = + documentAiService + .run( + Select.from(SourceDocument_.class) + .columns( + SourceDocument.ID, + SourceDocument.FILE_NAME, + SourceDocument.MIME_TYPE, + SourceDocument.CONTENT) + .byId(sourceDocumentId)) + .first(SourceDocument.class) + .orElse(null); + + if (document == null) { + throw new SourceDocumentException.NotFound(sourceDocumentId); + } + + InputStream content = document.getContent(); + if (content == null) { + throw new SourceDocumentException.ContentMissing(sourceDocumentId); + } + + ExtractionResult extraction = + extractionService.triggerExtraction( + sourceDocumentId, document.getFileName(), document.getMimeType(), content, tenantId); + + ExtractionJob result = ExtractionJob.create(); + result.setId(extraction.internalJobId()); + result.setSourceDocumentId(sourceDocumentId); + context.setResult(result); + } + + @After(event = CqnService.EVENT_UPDATE, entity = SourceDocument_.CDS_NAME) + public void afterContentUpload(CdsUpdateEventContext context) { + + // Trigger extraction only when document content was updated + boolean contentUpdated = + context.getCqn().entries().stream() + .anyMatch(entry -> entry.containsKey(SourceDocument.CONTENT)); + + if (!contentUpdated) { + logger.debug( + "[sap-document-ai] SourceDocument UPDATE contained no content changes, skipping extraction"); + return; + } + + List sourceDocumentIds = + context.getResult().listOf(SourceDocument.class).stream() + .map(SourceDocument::getId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (sourceDocumentIds.isEmpty()) { + logger.debug("[sap-document-ai] No SourceDocument IDs in result, skipping extraction"); + return; + } + + String tenantId = context.getUserInfo().getTenant(); + List failedIds = new ArrayList<>(); + + List documents = + documentAiService + .run( + Select.from(SourceDocument_.class) + .columns( + SourceDocument.ID, + SourceDocument.FILE_NAME, + SourceDocument.MIME_TYPE, + SourceDocument.CONTENT) + .where(d -> d.get(SourceDocument.ID).in(sourceDocumentIds))) + .listOf(SourceDocument.class); + + for (SourceDocument document : documents) { + InputStream content = document.getContent(); + String sourceDocumentId = document.getId(); + + if (content == null) { + logger.warn( + "[sap-document-ai] Content is null for sourceDocumentId={}, skipping extraction", + sourceDocumentId); + failedIds.add(sourceDocumentId); + continue; + } + + try { + logger.info( + "[sap-document-ai] Content uploaded for sourceDocumentId={}, triggering extraction", + sourceDocumentId); + ExtractionResult extraction = + extractionService.triggerExtraction( + sourceDocumentId, + document.getFileName(), + document.getMimeType(), + content, + tenantId); + switch (extraction.status()) { + case FAILED -> { + logger.error( + "[sap-document-ai] Extraction failed for sourceDocumentId={}", sourceDocumentId); + failedIds.add(sourceDocumentId); + } + case PENDING -> + logger.warn( + "[sap-document-ai] Document AI unavailable, sourceDocumentId={} left as PENDING", + sourceDocumentId); + default -> {} + } + } catch (Exception e) { + logger.error( + "[sap-document-ai] Extraction failed for sourceDocumentId={}", sourceDocumentId, e); + failedIds.add(sourceDocumentId); + } + } + + if (!failedIds.isEmpty()) { + logger.error( + "[sap-document-ai] Extraction failed for {} of {} document(s): {}", + failedIds.size(), + documents.size(), + failedIds); + } + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index 175a0a7..024a5ab 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -4,7 +4,7 @@ package com.sap.cds.service; import com.sap.cds.service.documentai.client.DocumentAiClient; -import com.sap.cds.service.exceptions.DocumentAiProcessingException; +import com.sap.cds.service.exceptions.DocumentAiException; import com.sap.cds.service.model.DocumentInput; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +36,7 @@ public String processDocument(String jobId, DocumentInput documentInput) { documentAiJobId); return documentAiJobId; } catch (Exception e) { - throw new DocumentAiProcessingException("Failed to process document for jobId=" + jobId, e); + throw new DocumentAiException.Processing("Failed to process document for jobId=" + jobId, e); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index 5488cff..fd4d2e0 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -3,11 +3,20 @@ */ package com.sap.cds.service; +import com.sap.cds.service.model.ExtractionResult; import com.sap.cds.services.Service; +import java.io.InputStream; public interface ExtractionService extends Service { String NAME = "ExtractionService"; String EVENT_START_EXTRACTION = "startExtraction"; + + ExtractionResult triggerExtraction( + String sourceDocumentId, + String fileName, + String mimeType, + InputStream content, + String tenantId); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index bc2abb8..67ecb21 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -17,6 +17,10 @@ import com.sap.cds.reflect.CdsEntity; import com.sap.cds.service.exceptions.IllegalStatusTransitionException; import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.service.model.ExtractionResult; +import com.sap.cds.service.model.ExtractionResult.Status; +import com.sap.cds.service.model.ExtractionSource; +import com.sap.cds.service.utils.StatusTransitionValidator; import com.sap.cds.services.ServiceDelegator; import com.sap.cds.services.draft.Drafts; import com.sap.cds.services.handler.annotations.On; @@ -46,6 +50,33 @@ public void init( this.documentAiProcessingService = documentAiProcessingService; } + @Override + public ExtractionResult triggerExtraction( + String sourceDocumentId, + String fileName, + String mimeType, + InputStream content, + String tenantId) { + logger.info( + "[sap-document-ai] Direct extraction triggered for sourceDocumentId={}, tenantId={}", + sourceDocumentId, + tenantId); + // create pending job + String jobId = createExtractionJob(ExtractionSource.sourceDocument(sourceDocumentId), tenantId); + + // check for availability of the service. + 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); + ExtractionResult extractionResult = + performExtraction(jobId, sourceDocumentId, documentInput, tenantId); + return extractionResult; + } + @On(event = EVENT_START_EXTRACTION) public void onStartExtraction(StartExtractionEventContext context) { context.setCompleted(); @@ -65,7 +96,6 @@ public void onStartExtraction(StartExtractionEventContext context) { "[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", attachmentId, tenantId); - CdsEntity attachmentEntity = context.getCdsRuntime().getCdsModel().getEntity(context.getAttachmentEntityName()); @@ -85,72 +115,76 @@ public void onStartExtraction(StartExtractionEventContext context) { return; } - DocumentInput documentInput = - new DocumentInput(fileName, contentId, mimeType, attachmentContent); + DocumentInput documentInput = new DocumentInput(fileName, mimeType, attachmentContent); + String jobId = createExtractionJob(ExtractionSource.attachment(attachmentId), tenantId); logger.info( "[sap-document-ai] Triggering extraction for attachmentId={}, contentId={}", attachmentId, contentId); - String jobId = createExtractionJob(attachmentId, tenantId); + ExtractionResult result = performExtraction(jobId, attachmentId, documentInput, tenantId); + // log temporarily + if (result.status() == ExtractionResult.Status.FAILED) { + logger.warn( + "[sap-document-ai] Extraction failed for attachmentId={}, jobId={}", attachmentId, jobId); + } + } + + private ExtractionResult performExtraction( + String jobId, String sourceId, DocumentInput documentInput, String tenantId) { try { String documentAiJobId = documentAiProcessingService.processDocument(jobId, documentInput); - updateStatusAndSetDocumentAiJobId(jobId, SUBMITTED, documentAiJobId); - + updateExtractionJob(jobId, SUBMITTED, documentAiJobId); // TODO: transition to PROCESSING and COMPLETED via async polling callback, not here - // updateStatus(jobId, PROCESSING); - // updateStatus(jobId, COMPLETED); + // updateExtractionJob(jobId, PROCESSING, null); // or replace w/ documentAiJobId + // updateExtractionJob(jobId, COMPLETED, null); // or replace w/ documentAiJobId + return new ExtractionResult(jobId, Status.SUCCESS, documentAiJobId); } catch (IllegalStatusTransitionException e) { // example: COMPLETED -> FAILED logger.error("[sap-document-ai] Invalid state transition for jobId={}", jobId, e); + throw e; } catch (Exception e) { // example : PROCESSING -> FAILED logger.error( - "[sap-document-ai] Processing failed for attachmentId={}, tenantId={}", - attachmentId, + "[sap-document-ai] Processing failed for sourceId={}, tenantId={}", + sourceId, tenantId, e); markJobAsFailed(jobId); + return new ExtractionResult(jobId, Status.FAILED, null); } } private void markJobAsFailed(String jobId) { try { - updateStatus(jobId, FAILED); + updateExtractionJob(jobId, FAILED, null); } catch (Exception e) { logger.error("[sap-document-ai] Failed to update status to FAILED for jobId={}", jobId, e); } } - private String createExtractionJob(String attachmentId, String tenantId) { + private String createExtractionJob(ExtractionSource source, String tenantId) { ExtractionJob job = ExtractionJob.create(); - job.setAttachmentId(attachmentId); + job.setAttachmentId( + source.attachmentId()); // if the entry point is via attachments, this is populated + job.setSourceDocumentId( + source.sourceDocumentId()); // if it's standalone via OData APIs, then this is populated job.setTenantId(tenantId); job.setStatus(PENDING.name()); Result result = persistenceService.run(Insert.into(ExtractionJob_.class).entry(job)); String jobId = result.single(ExtractionJob.class).getId(); + boolean isAttachment = source.attachmentId() != null; logger.info( - "[sap-document-ai] ExtractionJob created with status=Pending for attachmentId={} & jobId={}", - attachmentId, + "[sap-document-ai] ExtractionJob created with status=PENDING, sourceType={}, sourceId={}, jobId={}", + isAttachment ? "attachment" : "sourceDocument", + isAttachment ? source.attachmentId() : source.sourceDocumentId(), jobId); return jobId; } - private void updateStatus(String jobId, ExtractionStatus status) { - updateExtractionJob(jobId, status, null); - } - - private void updateStatusAndSetDocumentAiJobId( - String jobId, ExtractionStatus status, String documentAiJobId) { - - updateExtractionJob(jobId, status, documentAiJobId); - } - private void updateExtractionJob(String jobId, ExtractionStatus status, String documentAiJobId) { - Result current = persistenceService.run(Select.from(ExtractionJob_.class).byId(jobId)); - ExtractionStatus currentStatus = - ExtractionStatus.valueOf(current.single(ExtractionJob.class).getStatus()); + ExtractionStatus currentStatus = fromString(current.single(ExtractionJob.class).getStatus()); if (currentStatus.equals(status)) { logger.debug( @@ -171,7 +205,25 @@ private void updateExtractionJob(String jobId, ExtractionStatus status, String d extractionJob.setDocumentAiJobId(documentAiJobId); } - persistenceService.run(Update.entity(ExtractionJob_.class).byId(jobId).entry(extractionJob)); + Result updateResult = + persistenceService.run( + Update.entity(ExtractionJob_.class) + .byId(jobId) + .where(j -> j.get(ExtractionJob.STATUS).eq(currentStatus.name())) + .entry(extractionJob)); + + if (updateResult.rowCount() == 0) { + logger.error( + "[sap-document-ai] Status update skipped for jobId={} — concurrent modification detected (expected status={}, update affected 0 rows)", + jobId, + currentStatus); + throw new IllegalStatusTransitionException( + "Concurrent modification detected for jobId=" + + jobId + + ", expected status=" + + currentStatus); + } + logger.info( "[sap-document-ai] ExtractionJob jobId={} status updated from {} to {}{}", jobId, @@ -186,33 +238,34 @@ private Optional getAttachment(CdsEntity attachmentEntity, String c contentId, attachmentEntity.getQualifiedName()); - return selectData(attachmentEntity, contentId).stream() - .filter( - result -> { - long rowCount = result.result().rowCount(); - if (rowCount <= 0) { - logger.info( - "No attachment {} found in entity {}.", - contentId, - result.entity().getQualifiedName()); - return false; - } - if (rowCount > 1) { - throw new IllegalStateException( - "More than one attachment with contentId %s.".formatted(contentId)); - } - return true; - }) - .findFirst() - .map( - r -> { - Attachments found = r.result().single(Attachments.class); - logger.debug( - "Found attachment {} in entity {}.", - found.getContentId(), - r.entity().getQualifiedName()); - return found; - }); + Optional attachmentsOptional = + selectData(attachmentEntity, contentId).stream() + .filter(result -> hasExactlyOneAttachment(contentId, result)) + .findFirst() + .map( + r -> { + Attachments found = r.result().single(Attachments.class); + logger.debug( + "Found attachment {} in entity {}.", + found.getContentId(), + r.entity().getQualifiedName()); + return found; + }); + return attachmentsOptional; + } + + private static boolean hasExactlyOneAttachment(String contentId, SelectionResult result) { + long rowCount = result.result().rowCount(); + if (rowCount <= 0) { + logger.info( + "No attachment {} found in entity {}.", contentId, result.entity().getQualifiedName()); + return false; + } + if (rowCount > 1) { + throw new IllegalStateException( + "More than one attachment with contentId %s.".formatted(contentId)); + } + return true; } private List selectData(CdsEntity attachmentEntity, String contentId) { diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java index 265e634..d2c26fb 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java @@ -9,4 +9,13 @@ public enum ExtractionStatus { PROCESSING, COMPLETED, FAILED; + + 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/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java index 35392e9..f18977e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java @@ -6,13 +6,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.service.exceptions.DocumentAiConnectivityException; -import com.sap.cds.service.exceptions.DocumentAiRequestException; +import com.sap.cds.service.exceptions.DocumentAiException; import com.sap.cds.service.model.DocumentInput; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStream; import java.net.URI; import java.util.List; import java.util.Map; @@ -53,23 +50,11 @@ private URI buildSubmitUri() { } private HttpPost buildSubmitRequest(DocumentInput documentInput, URI submitUri) { - InputStream content = documentInput.content(); logger.info( - "[sap-document-ai] Submitting document to DIE at url={} with content={}", + "[sap-document-ai] Submitting document to DIE at url={}, fileName={}, mimeType={}", submitUri, - content); - - byte[] bytes; - try { - bytes = content.readAllBytes(); - logger.info( - "[sap-document-ai] fileName={}, mimeType={}, size={} bytes", - documentInput.fileName(), - documentInput.mimeType(), - bytes.length); - } catch (IOException e) { - throw new RuntimeException("Failed to read document content", e); - } + documentInput.fileName(), + documentInput.mimeType()); String optionsJson = buildOptionsJson(); @@ -80,8 +65,7 @@ private HttpPost buildSubmitRequest(DocumentInput documentInput, URI submitUri) HttpPost request = new HttpPost(submitUri); request.setEntity( MultipartEntityBuilder.create() - .addBinaryBody( - "file", new ByteArrayInputStream(bytes), contentType, documentInput.fileName()) + .addBinaryBody("file", documentInput.content(), contentType, documentInput.fileName()) .addTextBody("options", optionsJson, ContentType.APPLICATION_JSON) .build()); @@ -98,13 +82,13 @@ private String executeRequest(HttpPost request, URI submitUri) { int statusCode = response.getStatusLine().getStatusCode(); if (statusCode < 200 || statusCode >= 300) { - throw new DocumentAiRequestException(statusCode, body); + throw new DocumentAiException.Request(statusCode, body); } return body; } catch (IOException e) { - throw new DocumentAiConnectivityException(submitUri.toString(), e); + throw new DocumentAiException.Connectivity(submitUri.toString(), e); } } @@ -136,10 +120,6 @@ private String buildOptionsJson() { "templateId", "detect", "candidateTemplateIds", List.of(), "enrichment", Map.of()); - try { - return objectMapper.writeValueAsString(options); - } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to serialize options", e); - } + return objectMapper.valueToTree(options).toString(); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiConnectivityException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiConnectivityException.java deleted file mode 100644 index e3cfc5e..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiConnectivityException.java +++ /dev/null @@ -1,13 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds.service.exceptions; - -import java.io.IOException; - -public class DocumentAiConnectivityException extends RuntimeException { - - public DocumentAiConnectivityException(String url, IOException cause) { - super("Failed to connect to DIE at " + url, cause); - } -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java new file mode 100644 index 0000000..6e7da0c --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java @@ -0,0 +1,40 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.exceptions; + +import java.io.IOException; + +public class DocumentAiException extends RuntimeException { + + protected DocumentAiException(String message, Throwable cause) { + super(message, cause); + } + + protected DocumentAiException(String message) { + super(message); + } + + public static class Connectivity extends DocumentAiException { + public Connectivity(String url, IOException cause) { + super("Failed to connect to DIE at " + url, cause); + } + } + + public static class Request extends DocumentAiException { + public final int statusCode; + public final String responseBody; + + public Request(int statusCode, String responseBody) { + super("DIE request failed. Status=" + statusCode + ", body=" + responseBody); + this.statusCode = statusCode; + this.responseBody = responseBody; + } + } + + public static class Processing extends DocumentAiException { + public Processing(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiProcessingException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiProcessingException.java deleted file mode 100644 index 173fce6..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiProcessingException.java +++ /dev/null @@ -1,11 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds.service.exceptions; - -public class DocumentAiProcessingException extends RuntimeException { - - public DocumentAiProcessingException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiRequestException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiRequestException.java deleted file mode 100644 index 940f1a6..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiRequestException.java +++ /dev/null @@ -1,16 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds.service.exceptions; - -public class DocumentAiRequestException extends RuntimeException { - - public final int statusCode; - public final String responseBody; - - public DocumentAiRequestException(int statusCode, String responseBody) { - super("DIE request failed. Status=" + statusCode + ", body=" + responseBody); - this.statusCode = statusCode; - this.responseBody = responseBody; - } -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/SourceDocumentException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/SourceDocumentException.java new file mode 100644 index 0000000..efb080d --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/SourceDocumentException.java @@ -0,0 +1,27 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.exceptions; + +import com.sap.cds.services.ErrorStatus; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; + +public class SourceDocumentException extends ServiceException { + + protected SourceDocumentException(ErrorStatus status, String message) { + super(status, message); + } + + public static class NotFound extends SourceDocumentException { + public NotFound(String sourceDocumentId) { + super(ErrorStatuses.NOT_FOUND, "SourceDocument not found: " + sourceDocumentId); + } + } + + public static class ContentMissing extends SourceDocumentException { + public ContentMissing(String sourceDocumentId) { + super(ErrorStatuses.BAD_REQUEST, "No content uploaded for: " + sourceDocumentId); + } + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java index 98a99f8..8f71a69 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java @@ -5,5 +5,4 @@ import java.io.InputStream; -public record DocumentInput( - String fileName, String contentId, String mimeType, InputStream content) {} +public record DocumentInput(String fileName, String mimeType, InputStream content) {} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java new file mode 100644 index 0000000..94fb3c0 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java @@ -0,0 +1,13 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.model; + +public record ExtractionResult(String internalJobId, Status status, String documentAiJobId) { + + public enum Status { + SUCCESS, + PENDING, + FAILED + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionSource.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionSource.java new file mode 100644 index 0000000..8205958 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionSource.java @@ -0,0 +1,22 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.model; + +public record ExtractionSource(String attachmentId, String sourceDocumentId) { + + public ExtractionSource { + if ((attachmentId == null) == (sourceDocumentId == null)) { + throw new IllegalArgumentException( + "Exactly one of attachmentId or sourceDocumentId must be provided"); + } + } + + public static ExtractionSource attachment(String attachmentId) { + return new ExtractionSource(attachmentId, null); + } + + public static ExtractionSource sourceDocument(String sourceDocumentId) { + return new ExtractionSource(null, sourceDocumentId); + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java b/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java similarity index 72% rename from sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java rename to sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java index 2dea219..5776e6a 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/StatusTransitionValidator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java @@ -1,15 +1,17 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.service.utils; import static com.sap.cds.service.ExtractionStatus.*; -class StatusTransitionValidator { +import com.sap.cds.service.ExtractionStatus; + +public class StatusTransitionValidator { private StatusTransitionValidator() {} - static boolean isValid(ExtractionStatus current, ExtractionStatus next) { + public static boolean isValid(ExtractionStatus current, ExtractionStatus next) { if (current.equals(next)) return true; // idempotent return switch (current) { diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds new file mode 100644 index 0000000..f668bb6 --- /dev/null +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds @@ -0,0 +1,17 @@ +namespace sap.document.ai; + +using {sap.document.ai as ai} from './extraction-job'; + +service DocumentAiService { + entity SourceDocument as projection on ai.SourceDocument; + + @readonly + entity ExtractionJob as projection on ai.ExtractionJob + excluding { + attachmentId, + tenantId, + documentAiJobId + }; + + action startExtraction(sourceDocumentId : UUID) returns ExtractionJob; +} diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index 6b94260..55817aa 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -5,10 +5,16 @@ using { managed } from '@sap/cds/common'; -@assert.unique: { attachmentId: [attachmentId] } +entity SourceDocument : cuid { + fileName : String; + mimeType : String @Core.IsMediaType; + content : LargeBinary @Core.MediaType: mimeType; +} + entity ExtractionJob : cuid, managed { - attachmentId : String; - status : String; - tenantId:String; + sourceDocument : Association to SourceDocument; + attachmentId : String; + status : String; + tenantId : String; documentAiJobId : String; } diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds index ffcec72..0e60ba7 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds @@ -1 +1,2 @@ using from './extraction-job'; +using from './document-ai-service'; diff --git a/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java new file mode 100644 index 0000000..f2b460a --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java @@ -0,0 +1,265 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +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.SourceDocument; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.StartExtractionContext; +import com.sap.cds.handlers.DocumentSubmissionHandler; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.service.ExtractionService; +import com.sap.cds.service.model.ExtractionResult; +import com.sap.cds.services.cds.CdsUpdateEventContext; +import com.sap.cds.services.request.UserInfo; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentSubmissionHandlerTest { + + private static final String SOURCE_DOCUMENT_ID = "src-doc-123"; + 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 DocumentAiService documentAiService; + @Mock CdsUpdateEventContext context; + @Mock StartExtractionContext startExtractionContext; + @Mock UserInfo userInfo; + @Mock Result selectResult; + @Mock Result updateResult; + @Mock CqnUpdate cqnUpdate; + + DocumentSubmissionHandler handler; + + @BeforeEach + void setUp() { + handler = new DocumentSubmissionHandler(extractionService, documentAiService); + } + + @Test + void onStartExtraction_returnsExtractionJobOnSuccess() { + when(startExtractionContext.getSourceDocumentId()).thenReturn(SOURCE_DOCUMENT_ID); + when(startExtractionContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); + + SourceDocument doc = + createDocument(SOURCE_DOCUMENT_ID, new ByteArrayInputStream("pdf-bytes".getBytes())); + when(selectResult.first(SourceDocument.class)).thenReturn(java.util.Optional.of(doc)); + + ExtractionResult extraction = + new ExtractionResult("job-123", ExtractionResult.Status.SUCCESS, "dai-job-456"); + when(extractionService.triggerExtraction( + eq(SOURCE_DOCUMENT_ID), + eq(FILE_NAME), + eq(MIME_TYPE), + any(InputStream.class), + eq(TENANT_ID))) + .thenReturn(extraction); + + handler.onStartExtraction(startExtractionContext); + + verify(startExtractionContext).setResult(argThat(job -> "job-123".equals(job.getId()))); + } + + @Test + void onStartExtraction_throwsNotFoundWhenDocumentNotFound() { + when(startExtractionContext.getSourceDocumentId()).thenReturn(SOURCE_DOCUMENT_ID); + when(startExtractionContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); + when(selectResult.first(SourceDocument.class)).thenReturn(java.util.Optional.empty()); + + assertThrows( + com.sap.cds.service.exceptions.SourceDocumentException.NotFound.class, + () -> handler.onStartExtraction(startExtractionContext)); + } + + @Test + void onStartExtraction_throwsContentMissingWhenContentIsNull() { + when(startExtractionContext.getSourceDocumentId()).thenReturn(SOURCE_DOCUMENT_ID); + when(startExtractionContext.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); + + SourceDocument doc = createDocument(SOURCE_DOCUMENT_ID, null); + when(selectResult.first(SourceDocument.class)).thenReturn(java.util.Optional.of(doc)); + + assertThrows( + com.sap.cds.service.exceptions.SourceDocumentException.ContentMissing.class, + () -> handler.onStartExtraction(startExtractionContext)); + } + + @Test + void afterContentUpload_triggersExtraction() { + when(cqnUpdate.entries()).thenReturn(List.of(entryWithContent(SOURCE_DOCUMENT_ID))); + when(context.getCqn()).thenReturn(cqnUpdate); + mockUserInfo(); + + SourceDocument idOnly = SourceDocument.create(); + idOnly.setId(SOURCE_DOCUMENT_ID); + when(context.getResult()).thenReturn(updateResult); + when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of(idOnly)); + + SourceDocument doc = + createDocument(SOURCE_DOCUMENT_ID, new ByteArrayInputStream("pdf-bytes".getBytes())); + when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); + when(selectResult.listOf(SourceDocument.class)).thenReturn(List.of(doc)); + + handler.afterContentUpload(context); + + verify(extractionService) + .triggerExtraction( + eq(SOURCE_DOCUMENT_ID), + eq(FILE_NAME), + eq(MIME_TYPE), + any(InputStream.class), + eq(TENANT_ID)); + } + + @Test + void afterContentUpload_skipsWhenNoContentInUpdate() { + when(cqnUpdate.entries()).thenReturn(List.of(Map.of(SourceDocument.ID, SOURCE_DOCUMENT_ID))); + when(context.getCqn()).thenReturn(cqnUpdate); + + handler.afterContentUpload(context); + + verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); + verify(documentAiService, never()).run(any(CqnSelect.class)); + } + + @Test + void afterContentUpload_skipsWhenIdMissing() { + when(cqnUpdate.entries()).thenReturn(List.of(Map.of())); + when(context.getCqn()).thenReturn(cqnUpdate); + + handler.afterContentUpload(context); + + verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); + verify(documentAiService, never()).run(any(CqnSelect.class)); + } + + @Test + void afterContentUpload_skipsWhenResultReturnsNoIds() { + Map entry = new HashMap<>(); + entry.put(SourceDocument.CONTENT, new ByteArrayInputStream("pdf-bytes".getBytes())); + when(cqnUpdate.entries()).thenReturn(List.of(entry)); + when(context.getCqn()).thenReturn(cqnUpdate); + + when(context.getResult()).thenReturn(updateResult); + when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of()); + + handler.afterContentUpload(context); + + verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); + verify(documentAiService, never()).run(any(CqnSelect.class)); + } + + @Test + void afterContentUpload_skipsWhenDocumentNotFound() { + when(cqnUpdate.entries()).thenReturn(List.of(entryWithContent(SOURCE_DOCUMENT_ID))); + when(context.getCqn()).thenReturn(cqnUpdate); + mockUserInfo(); + + SourceDocument idOnly = SourceDocument.create(); + idOnly.setId(SOURCE_DOCUMENT_ID); + when(context.getResult()).thenReturn(updateResult); + when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of(idOnly)); + + when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); + when(selectResult.listOf(SourceDocument.class)).thenReturn(List.of()); + + handler.afterContentUpload(context); + + verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); + } + + @Test + void afterContentUpload_skipsWhenContentIsNull() { + when(cqnUpdate.entries()).thenReturn(List.of(entryWithContent(SOURCE_DOCUMENT_ID))); + when(context.getCqn()).thenReturn(cqnUpdate); + mockUserInfo(); + + SourceDocument idOnly = SourceDocument.create(); + idOnly.setId(SOURCE_DOCUMENT_ID); + when(context.getResult()).thenReturn(updateResult); + when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of(idOnly)); + + SourceDocument doc = createDocument(SOURCE_DOCUMENT_ID, null); + when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); + when(selectResult.listOf(SourceDocument.class)).thenReturn(List.of(doc)); + + handler.afterContentUpload(context); + + verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); + } + + @Test + void afterContentUpload_continuesAndCollectsFailedIds() { + when(cqnUpdate.entries()) + .thenReturn(List.of(entryWithContent("src-1"), entryWithContent("src-2"))); + when(context.getCqn()).thenReturn(cqnUpdate); + mockUserInfo(); + + SourceDocument id1 = SourceDocument.create(); + id1.setId("src-1"); + SourceDocument id2 = SourceDocument.create(); + id2.setId("src-2"); + when(context.getResult()).thenReturn(updateResult); + when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of(id1, id2)); + + SourceDocument doc1 = createDocument("src-1", new ByteArrayInputStream("pdf-bytes".getBytes())); + SourceDocument doc2 = createDocument("src-2", new ByteArrayInputStream("pdf-bytes".getBytes())); + when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); + when(selectResult.listOf(SourceDocument.class)).thenReturn(List.of(doc1, doc2)); + doThrow(new RuntimeException("DIE unavailable")) + .when(extractionService) + .triggerExtraction(eq("src-1"), any(), any(), any(), any()); + + handler.afterContentUpload(context); + + verify(extractionService) + .triggerExtraction( + eq("src-1"), eq(FILE_NAME), eq(MIME_TYPE), any(InputStream.class), eq(TENANT_ID)); + verify(extractionService) + .triggerExtraction( + eq("src-2"), eq(FILE_NAME), eq(MIME_TYPE), any(InputStream.class), eq(TENANT_ID)); + } + + private void mockUserInfo() { + when(context.getUserInfo()).thenReturn(userInfo); + when(userInfo.getTenant()).thenReturn(TENANT_ID); + } + + private Map entryWithContent(String id) { + Map entry = new HashMap<>(); + entry.put(SourceDocument.ID, id); + entry.put(SourceDocument.CONTENT, new ByteArrayInputStream("pdf-bytes".getBytes())); + return entry; + } + + private SourceDocument createDocument(String id, InputStream content) { + SourceDocument doc = SourceDocument.create(); + doc.setId(id); + doc.setFileName(FILE_NAME); + doc.setMimeType(MIME_TYPE); + doc.setContent(content); + return doc; + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java b/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java index e8c54af..fd8c6dd 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java @@ -5,109 +5,122 @@ 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.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; +import com.sap.cds.handlers.AttachmentEventHandler; +import com.sap.cds.handlers.DocumentSubmissionHandler; import com.sap.cds.service.DefaultDocumentAiProcessingService; +import com.sap.cds.service.ExtractionService; +import com.sap.cds.service.ExtractionServiceImpl; import com.sap.cds.service.documentai.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.outbox.OutboxService; +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.HttpClientAccessor; -import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader; -import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions; -import java.util.function.Consumer; +import java.util.List; import java.util.stream.Stream; -import org.apache.http.client.HttpClient; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class AttachmentEventHandlerRegistrationTest { + @Mock CdsRuntimeConfigurer configurer; + @Mock CdsRuntime cdsRuntime; + @Mock ServiceCatalog serviceCatalog; + @Mock PersistenceService persistenceService; + @Mock OutboxService outboxService; + @Mock DocumentAiService documentAiService; @Mock CdsEnvironment environment; - @Mock ServiceBinding serviceBinding; + AttachmentEventHandlerRegistration registration; - @Mock HttpDestination httpDestination; + @BeforeEach + void setUp() { + registration = new AttachmentEventHandlerRegistration(); + } - @Mock ServiceBindingDestinationLoader destinationLoader; + @Test + void servicesRegistersExtractionService() { + registration.services(configurer); - @Mock HttpClient httpClient; + ArgumentCaptor captor = ArgumentCaptor.forClass(Service.class); + verify(configurer).service(captor.capture()); + assertThat(captor.getValue()).isInstanceOf(ExtractionServiceImpl.class); + } @Test - void buildDocumentAi_noBindingFound_returnsNull() { + void eventHandlersRegistersAttachmentAndDocumentSubmissionHandlers() { + when(configurer.getCdsRuntime()).thenReturn(cdsRuntime); + when(cdsRuntime.getServiceCatalog()).thenReturn(serviceCatalog); + when(cdsRuntime.getEnvironment()).thenReturn(environment); when(environment.getServiceBindings()).thenReturn(Stream.empty()); - DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); - assertThat(result).isNull(); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); + when(serviceCatalog.getService(OutboxService.class, OutboxService.PERSISTENT_UNORDERED_NAME)) + .thenReturn(outboxService); + when(serviceCatalog.getService(DocumentAiService.class, DocumentAiService_.CDS_NAME)) + .thenReturn(documentAiService); + ExtractionService outboxed = mock(ExtractionService.class); + doReturn(outboxed).when(outboxService).outboxed(any(ExtractionService.class)); + + registration.services(configurer); + registration.eventHandlers(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); + verify(configurer, atLeast(2)).eventHandler(captor.capture()); + + List handlers = captor.getAllValues(); + assertThat(handlers.stream().anyMatch(h -> h.getClass() == AttachmentEventHandler.class)) + .isTrue(); + assertThat(handlers.stream().anyMatch(h -> h.getClass() == DocumentSubmissionHandler.class)) + .isTrue(); } @Test - void buildDocumentAi_bindingFound_destinationCreated_returnsClient() { - // Arrange - when(environment.getServiceBindings()).thenReturn(Stream.of(serviceBinding)); - - withStaticMocks( - mocks -> { - mocks - .destinationLoader - .when(ServiceBindingDestinationLoader::defaultLoaderChain) - .thenReturn(destinationLoader); - when(destinationLoader.getDestination(any(ServiceBindingDestinationOptions.class))) - .thenReturn(httpDestination); - mocks - .clientAccessor - .when(() -> HttpClientAccessor.getHttpClient(httpDestination)) - .thenReturn(httpClient); - // Act - DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); - // Assert - assertThat(result).isNotNull(); - }); + void buildDocumentAi_noBindingFound_returnsNull() { + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + + DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); + + assertThat(result).isNull(); } @Test void buildDocumentAi_bindingFound_destinationCreationFails_returnsNull() { - // Arrange - when(environment.getServiceBindings()).thenReturn(Stream.of(serviceBinding)); - - withStaticMocks( - mocks -> { - mocks - .destinationLoader - .when(ServiceBindingDestinationLoader::defaultLoaderChain) - .thenReturn(destinationLoader); - when(destinationLoader.getDestination(any(ServiceBindingDestinationOptions.class))) - .thenThrow(new RuntimeException("failed to create destination")); - // Act - DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); - // Assert - assertThat(result).isNull(); - }); - } + ServiceBinding binding = mock(ServiceBinding.class); + when(environment.getServiceBindings()).thenReturn(Stream.of(binding)); - private void withStaticMocks(Consumer test) { - try (MockedStatic utilsMockedStatic = - mockStatic(ServiceBindingUtils.class); - MockedStatic destinationLoaderMockedStatic = - mockStatic(ServiceBindingDestinationLoader.class); - MockedStatic clientAccessorMockedStatic = - mockStatic(HttpClientAccessor.class)) { - utilsMockedStatic + try (var utils = mockStatic(ServiceBindingUtils.class); + var loader = mockStatic(ServiceBindingDestinationLoader.class)) { + utils .when( () -> ServiceBindingUtils.matches( - serviceBinding, - DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL)) + any(), eq(DefaultDocumentAiProcessingService.SAP_DOCUMENT_AI_SERVICE_LABEL))) .thenReturn(true); - test.accept(new StaticMocks(destinationLoaderMockedStatic, clientAccessorMockedStatic)); + + ServiceBindingDestinationLoader loaderMock = mock(ServiceBindingDestinationLoader.class); + loader.when(ServiceBindingDestinationLoader::defaultLoaderChain).thenReturn(loaderMock); + when(loaderMock.getDestination(any())).thenThrow(new RuntimeException("destination fail")); + + DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); + + assertThat(result).isNull(); } } - - private record StaticMocks( - MockedStatic destinationLoader, - MockedStatic clientAccessor) {} } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java index 39f984c..4836018 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -6,7 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import com.sap.cds.service.documentai.client.DocumentAiClient; -import com.sap.cds.service.exceptions.DocumentAiProcessingException; +import com.sap.cds.service.exceptions.DocumentAiException; import com.sap.cds.service.model.DocumentInput; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; @@ -21,7 +21,6 @@ class DefaultDocumentAiProcessingServiceTest { public static final String TEST_PDF = "test.pdf"; - public static final String CNT_ID_1 = "cnt_id_1"; public static final String CONTENT_TYPE = "application/pdf"; public static final String TEST_CONTENT = "test"; public static final String JOB_1 = "job-1"; @@ -38,7 +37,6 @@ void setUp() { documentInput = new DocumentInput( TEST_PDF, - CNT_ID_1, CONTENT_TYPE, new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8))); } @@ -66,6 +64,6 @@ void processDocumentThrowsWhenSubmitDocumentFails() { Mockito.when(documentAiClient.submitDocument(any())) .thenThrow(new RuntimeException("submit failed")); Assertions.assertThatThrownBy(() -> service.processDocument(JOB_1, documentInput)) - .isInstanceOf(DocumentAiProcessingException.class); + .isInstanceOf(DocumentAiException.Processing.class); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 29b57d2..bcd3009 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -4,8 +4,8 @@ package com.sap.cds.service; import static com.sap.cds.service.ExtractionStatus.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentCaptor.forClass; +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; @@ -18,6 +18,8 @@ import com.sap.cds.reflect.CdsElementNotFoundException; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsModel; +import com.sap.cds.service.exceptions.IllegalStatusTransitionException; +import com.sap.cds.service.model.ExtractionResult; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import java.io.ByteArrayInputStream; @@ -73,7 +75,7 @@ void startExtractionCreatesOneJobWithCorrectFields() { mockSuccessfulProcessing(); extractionService.onStartExtraction(eventContext()); - ArgumentCaptor insertCaptor = forClass(CqnInsert.class); + ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); verify(persistenceService, atLeastOnce()).run(insertCaptor.capture()); ExtractionJob inserted = Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); @@ -90,7 +92,7 @@ void startExtractionStoresDocumentAiJobIdAndUpdatesStatusToSubmitted() { mockSuccessfulProcessing(); extractionService.onStartExtraction(eventContext()); - ArgumentCaptor captor = forClass(CqnUpdate.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(CqnUpdate.class); verify(persistenceService, times(1)).run(captor.capture()); ExtractionJob update = @@ -131,7 +133,9 @@ void invalidTransitionIsLoggedAndNoStatusUpdateOccurs() { mockContentThenStatus(contentStream(), statusResult); mockInsertDatabaseCalls(); mockSuccessfulProcessing(); - extractionService.onStartExtraction(eventContext()); + assertThrows( + IllegalStatusTransitionException.class, + () -> extractionService.onStartExtraction(eventContext())); verify(persistenceService, never()).run(any(CqnUpdate.class)); } @@ -147,6 +151,67 @@ void markJobAsFailedSucceedsWhenTransitionFromPendingToFailedIsValid() { verify(persistenceService, times(1)).run(any(CqnUpdate.class)); } + @Test + void triggerExtractionCreatesJobAsPendingWhenServiceUnavailable() { + mockInsertDatabaseCalls(); // job creation must succeed + when(documentAiProcessingService.isAvailable()).thenReturn(false); + + ExtractionResult result = + extractionService.triggerExtraction( + ATT_123, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); + assertThat(result.internalJobId()).isNotNull(); + verify(persistenceService).run(any(CqnInsert.class)); // job was created + } + + @Test + void triggerExtractionMarksJobFailedOnInvalidStatusTransition() { + 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( + ATT_123, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1)); + + verify(persistenceService, never()).run(any(CqnUpdate.class)); + } + + @Test + void triggerExtractionSubmitsDocumentAndUpdatesStatus() { + 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(ATT_123, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); + } + + @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()); + + extractionService.triggerExtraction(ATT_123, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); + } + private StartExtractionEventContext eventContext() { StartExtractionEventContext ctx = mock(StartExtractionEventContext.class); lenient().when(ctx.getAttachmentId()).thenReturn(ATT_123); @@ -229,7 +294,9 @@ private void mockInsertDatabaseCalls() { private void mockAllDatabaseCalls() { mockInsertDatabaseCalls(); - lenient().when(persistenceService.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + 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) { diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java new file mode 100644 index 0000000..8090627 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java @@ -0,0 +1,105 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.documentai.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.service.exceptions.DocumentAiException; +import com.sap.cds.service.model.DocumentInput; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import org.apache.http.HttpEntity; +import org.apache.http.StatusLine; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +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 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 CloseableHttpResponse response; + @Mock StatusLine statusLine; + @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())); + 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(HttpUriRequest.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(RuntimeException.class) + .hasMessageContaining("Unexpected DIE response"); + } + + @Test + void submitDocumentThrowsWhenResponseIsNotValidJson() throws IOException { + mockHttpResponse(200, "not-json{{{{"); + + assertThatThrownBy(() -> client.submitDocument(documentInput)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to parse DIE response"); + } + + private void mockHttpResponse(int statusCode, String body) throws IOException { + when(httpClient.execute(any(HttpUriRequest.class))).thenReturn(response); + when(response.getStatusLine()).thenReturn(statusLine); + when(response.getEntity()).thenReturn(entity); + when(statusLine.getStatusCode()).thenReturn(statusCode); + when(entity.getContent()).thenReturn(new ByteArrayInputStream(body.getBytes())); + when(entity.getContentLength()).thenReturn(-1L); + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java new file mode 100644 index 0000000..4499b9c --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java @@ -0,0 +1,65 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.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 BAD_REQUEST = "Bad Request"; + DocumentAiException.Request ex = new DocumentAiException.Request(400, BAD_REQUEST); + + assertThat(ex.statusCode).isEqualTo(400); + assertThat(ex.responseBody).isEqualTo(BAD_REQUEST); + assertThat(ex.getMessage()).contains("400").contains(BAD_REQUEST); + } + + @Test + void illegalStatusTransitionExceptionContainsMessage() { + String message = "Invalid transition from PENDING to COMPLETED"; + IllegalStatusTransitionException ex = new IllegalStatusTransitionException(message); + + assertThat(ex.getMessage()).isEqualTo(message); + } + + @Test + void sourceDocumentNotFoundExceptionContainsSourceDocumentId() { + SourceDocumentException.NotFound ex = new SourceDocumentException.NotFound("src-123"); + + assertThat(ex.getMessage()).contains("src-123"); + } + + @Test + void sourceDocumentContentMissingExceptionContainsSourceDocumentId() { + SourceDocumentException.ContentMissing ex = + new SourceDocumentException.ContentMissing("src-123"); + + assertThat(ex.getMessage()).contains("src-123"); + } +} diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java similarity index 97% rename from sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java rename to sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java index 54b323d..b381c9e 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/StatusTransitionValidatorTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.service.utils; import static com.sap.cds.service.ExtractionStatus.*; From c9b4bf1808d9b3a28a7dea5d3db98dea0195bfdd Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:38:07 +0200 Subject: [PATCH 43/70] refactor: remove attachments plugin entry point --- sap-document-ai/pom.xml | 15 +- .../AttachmentEventHandlerRegistration.java | 36 --- .../cds/handlers/AttachmentEventHandler.java | 55 ----- .../cds/service/ExtractionServiceImpl.java | 137 +---------- .../service/StartExtractionEventContext.java | 39 --- .../cds/service/model/ExtractionSource.java | 22 -- .../sap-document-ai/document-ai-service.cds | 1 - .../sap-document-ai/extraction-job.cds | 1 - .../sap/cds/AttachmentEventHandlerTest.java | 84 ------- ...ttachmentEventHandlerRegistrationTest.java | 5 +- .../service/ExtractionServiceImplTest.java | 222 ++++-------------- 11 files changed, 55 insertions(+), 562 deletions(-) delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/StartExtractionEventContext.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionSource.java delete mode 100644 sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 46d257e..f058616 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -23,11 +23,6 @@ pom import - - com.sap.cds - cds-feature-attachments - 1.5.0 - @@ -49,11 +44,6 @@ 6.2.6 provided - - com.sap.cds - cds-feature-attachments - provided - org.apache.httpcomponents httpmime @@ -84,6 +74,11 @@ 3.26.3 test + + com.sap.cds + cds-services-utils + provided + com.sap.cds cds-services-impl diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java index 3102c65..869068a 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java @@ -5,17 +5,14 @@ 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.DocumentAiService_; -import com.sap.cds.handlers.AttachmentEventHandler; import com.sap.cds.handlers.DocumentSubmissionHandler; import com.sap.cds.service.DefaultDocumentAiProcessingService; import com.sap.cds.service.DocumentAiProcessingService; -import com.sap.cds.service.ExtractionService; import com.sap.cds.service.ExtractionServiceImpl; import com.sap.cds.service.documentai.client.DefaultDocumentAiClient; import com.sap.cds.service.documentai.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; @@ -68,42 +65,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { extractionService.init(persistenceService, documentAiProcessingService); - OutboxService outboxService = - serviceCatalog.getService(OutboxService.class, OutboxService.PERSISTENT_UNORDERED_NAME); - - ExtractionService outboxedExtractionService; - - if (outboxService != null) { - outboxedExtractionService = outboxService.outboxed(extractionService); - } else { - outboxedExtractionService = extractionService; - logger.warn( - "OutboxService '{}' is not available. AttachmentService will not be outboxed.", - OutboxService.PERSISTENT_UNORDERED_NAME); - } - - // register event handler with CAP runtime - if (isAttachmentsPluginPresent()) { - configurer.eventHandler(new AttachmentEventHandler(outboxedExtractionService)); - logger.info( - "[sap-document-ai] cds-feature-attachments detected, attachment handler registered"); - } else { - logger.info( - "[sap-document-ai] cds-feature-attachments not found, attachment handler skipped"); - } - configurer.eventHandler(new DocumentSubmissionHandler(extractionService, documentAiService)); } - private static boolean isAttachmentsPluginPresent() { - try { - Class.forName("com.sap.cds.feature.attachments.service.AttachmentService"); - return true; - } catch (ClassNotFoundException e) { - return false; - } - } - static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { Optional optionalBinding = environment diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java deleted file mode 100644 index 06906e2..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/AttachmentEventHandler.java +++ /dev/null @@ -1,55 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds.handlers; - -import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; -import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; -import com.sap.cds.feature.attachments.service.AttachmentService; -import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; -import com.sap.cds.service.ExtractionService; -import com.sap.cds.service.StartExtractionEventContext; -import com.sap.cds.services.handler.EventHandler; -import com.sap.cds.services.handler.annotations.After; -import com.sap.cds.services.handler.annotations.ServiceName; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@ServiceName(value = "*", type = AttachmentService.class) -public class AttachmentEventHandler implements EventHandler { - - private static final Logger logger = LoggerFactory.getLogger(AttachmentEventHandler.class); - - private final ExtractionService extractionService; - - public AttachmentEventHandler(ExtractionService extractionService) { - this.extractionService = extractionService; - } - - @After(event = AttachmentService.EVENT_CREATE_ATTACHMENT) - public void afterCreateAttachment(AttachmentCreateEventContext context) { - String attachmentId = (String) context.getAttachmentIds().get(Attachments.ID); - - if (attachmentId == null) { - logger.warn("[sap-document-ai] attachmentId is null, skipping extraction"); - return; - } - - MediaData data = context.getData(); - - StartExtractionEventContext eventContext = StartExtractionEventContext.create(); - eventContext.setAttachmentId(attachmentId); - eventContext.setContentId(context.getContentId()); - eventContext.setTenantId(context.getUserInfo().getTenant()); - eventContext.setFileName(data.getFileName()); - eventContext.setMimeType(data.getMimeType()); - eventContext.setAttachmentEntityName(context.getAttachmentEntity().getQualifiedName()); - - logger.info( - "[sap-document-ai] Queuing extraction for attachmentId={}, contentId={}", - attachmentId, - context.getContentId()); - - extractionService.emit(eventContext); - } -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 67ecb21..b788504 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -6,29 +6,19 @@ import static com.sap.cds.service.ExtractionStatus.*; import com.sap.cds.Result; -import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; 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.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.reflect.CdsElementNotFoundException; -import com.sap.cds.reflect.CdsEntity; import com.sap.cds.service.exceptions.IllegalStatusTransitionException; import com.sap.cds.service.model.DocumentInput; import com.sap.cds.service.model.ExtractionResult; import com.sap.cds.service.model.ExtractionResult.Status; -import com.sap.cds.service.model.ExtractionSource; import com.sap.cds.service.utils.StatusTransitionValidator; import com.sap.cds.services.ServiceDelegator; -import com.sap.cds.services.draft.Drafts; -import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.persistence.PersistenceService; import java.io.InputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -62,7 +52,7 @@ public ExtractionResult triggerExtraction( sourceDocumentId, tenantId); // create pending job - String jobId = createExtractionJob(ExtractionSource.sourceDocument(sourceDocumentId), tenantId); + String jobId = createExtractionJob(sourceDocumentId, tenantId); // check for availability of the service. if (!documentAiProcessingService.isAvailable()) { @@ -77,60 +67,6 @@ public ExtractionResult triggerExtraction( return extractionResult; } - @On(event = EVENT_START_EXTRACTION) - public void onStartExtraction(StartExtractionEventContext context) { - context.setCompleted(); - - if (!documentAiProcessingService.isAvailable()) { - logger.warn("[sap-document-ai] Document AI client is not available, skipping submission"); - return; - } - - String attachmentId = context.getAttachmentId(); - String tenantId = context.getTenantId(); - String fileName = context.getFileName(); - String contentId = context.getContentId(); - String mimeType = context.getMimeType(); - - logger.info( - "[sap-document-ai] Orchestrator triggered for attachmentId={}, tenantId={}", - attachmentId, - tenantId); - CdsEntity attachmentEntity = - context.getCdsRuntime().getCdsModel().getEntity(context.getAttachmentEntityName()); - - Optional row = getAttachment(attachmentEntity, contentId); - - if (row.isEmpty()) { - logger.warn("[sap-document-ai] No attachment found for contentId={}, skipping", contentId); - return; - } - - Attachments attachment = row.get(); - - InputStream attachmentContent = attachment.getContent(); - - if (attachmentContent == null) { - logger.warn("[sap-document-ai] Content is null for contentId={}, skipping", contentId); - return; - } - - DocumentInput documentInput = new DocumentInput(fileName, mimeType, attachmentContent); - String jobId = createExtractionJob(ExtractionSource.attachment(attachmentId), tenantId); - - logger.info( - "[sap-document-ai] Triggering extraction for attachmentId={}, contentId={}", - attachmentId, - contentId); - - ExtractionResult result = performExtraction(jobId, attachmentId, documentInput, tenantId); - // log temporarily - if (result.status() == ExtractionResult.Status.FAILED) { - logger.warn( - "[sap-document-ai] Extraction failed for attachmentId={}, jobId={}", attachmentId, jobId); - } - } - private ExtractionResult performExtraction( String jobId, String sourceId, DocumentInput documentInput, String tenantId) { try { @@ -162,22 +98,17 @@ private void markJobAsFailed(String jobId) { } } - private String createExtractionJob(ExtractionSource source, String tenantId) { + private String createExtractionJob(String sourceDocumentId, String tenantId) { ExtractionJob job = ExtractionJob.create(); - job.setAttachmentId( - source.attachmentId()); // if the entry point is via attachments, this is populated - job.setSourceDocumentId( - source.sourceDocumentId()); // if it's standalone via OData APIs, then this is populated + job.setSourceDocumentId(sourceDocumentId); job.setTenantId(tenantId); job.setStatus(PENDING.name()); Result result = persistenceService.run(Insert.into(ExtractionJob_.class).entry(job)); String jobId = result.single(ExtractionJob.class).getId(); - boolean isAttachment = source.attachmentId() != null; logger.info( - "[sap-document-ai] ExtractionJob created with status=PENDING, sourceType={}, sourceId={}, jobId={}", - isAttachment ? "attachment" : "sourceDocument", - isAttachment ? source.attachmentId() : source.sourceDocumentId(), + "[sap-document-ai] ExtractionJob created with status=PENDING, sourceId={}, jobId={}", + sourceDocumentId, jobId); return jobId; } @@ -231,62 +162,4 @@ private void updateExtractionJob(String jobId, ExtractionStatus status, String d status, documentAiJobId != null ? " with documentAiJobId=" + documentAiJobId : ""); } - - private Optional getAttachment(CdsEntity attachmentEntity, String contentId) { - logger.info( - "Started finding attachment {} of entity {}.", - contentId, - attachmentEntity.getQualifiedName()); - - Optional attachmentsOptional = - selectData(attachmentEntity, contentId).stream() - .filter(result -> hasExactlyOneAttachment(contentId, result)) - .findFirst() - .map( - r -> { - Attachments found = r.result().single(Attachments.class); - logger.debug( - "Found attachment {} in entity {}.", - found.getContentId(), - r.entity().getQualifiedName()); - return found; - }); - return attachmentsOptional; - } - - private static boolean hasExactlyOneAttachment(String contentId, SelectionResult result) { - long rowCount = result.result().rowCount(); - if (rowCount <= 0) { - logger.info( - "No attachment {} found in entity {}.", contentId, result.entity().getQualifiedName()); - return false; - } - if (rowCount > 1) { - throw new IllegalStateException( - "More than one attachment with contentId %s.".formatted(contentId)); - } - return true; - } - - private List selectData(CdsEntity attachmentEntity, String contentId) { - List result = new ArrayList<>(); - try { - CdsEntity sibling = attachmentEntity.getTargetOf(Drafts.SIBLING_ENTITY); - result.add(new SelectionResult(sibling, readData(contentId, sibling))); - } catch (CdsElementNotFoundException ignored) { - // no draft sibling — nothing to select - } - result.add(new SelectionResult(attachmentEntity, readData(contentId, attachmentEntity))); - return result; - } - - private Result readData(String contentId, CdsEntity entity) { - CqnSelect select = - Select.from(entity) - .columns(Attachments.CONTENT_ID, Attachments.CONTENT) - .where(e -> e.get(Attachments.CONTENT_ID).eq(contentId)); - return persistenceService.run(select); - } - - private record SelectionResult(CdsEntity entity, Result result) {} } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/StartExtractionEventContext.java b/sap-document-ai/src/main/java/com/sap/cds/service/StartExtractionEventContext.java deleted file mode 100644 index 8ceb562..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/service/StartExtractionEventContext.java +++ /dev/null @@ -1,39 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds.service; - -import com.sap.cds.services.EventContext; -import com.sap.cds.services.EventName; - -@EventName(ExtractionService.EVENT_START_EXTRACTION) -public interface StartExtractionEventContext extends EventContext { - - static StartExtractionEventContext create() { - return EventContext.create(StartExtractionEventContext.class, null); - } - - String getAttachmentId(); - - void setAttachmentId(String attachmentId); - - String getContentId(); - - void setContentId(String contentId); - - String getTenantId(); - - void setTenantId(String tenantId); - - String getFileName(); - - void setFileName(String fileName); - - String getMimeType(); - - void setMimeType(String mimeType); - - String getAttachmentEntityName(); - - void setAttachmentEntityName(String attachmentEntityName); -} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionSource.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionSource.java deleted file mode 100644 index 8205958..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionSource.java +++ /dev/null @@ -1,22 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds.service.model; - -public record ExtractionSource(String attachmentId, String sourceDocumentId) { - - public ExtractionSource { - if ((attachmentId == null) == (sourceDocumentId == null)) { - throw new IllegalArgumentException( - "Exactly one of attachmentId or sourceDocumentId must be provided"); - } - } - - public static ExtractionSource attachment(String attachmentId) { - return new ExtractionSource(attachmentId, null); - } - - public static ExtractionSource sourceDocument(String sourceDocumentId) { - return new ExtractionSource(null, sourceDocumentId); - } -} diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds index f668bb6..a161cbf 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds @@ -8,7 +8,6 @@ service DocumentAiService { @readonly entity ExtractionJob as projection on ai.ExtractionJob excluding { - attachmentId, tenantId, documentAiJobId }; diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index 55817aa..0b5370c 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -13,7 +13,6 @@ entity SourceDocument : cuid { entity ExtractionJob : cuid, managed { sourceDocument : Association to SourceDocument; - attachmentId : String; status : String; tenantId : String; documentAiJobId : String; diff --git a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java deleted file mode 100644 index 9d4c565..0000000 --- a/sap-document-ai/src/test/java/com/sap/cds/AttachmentEventHandlerTest.java +++ /dev/null @@ -1,84 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; -import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; -import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; -import com.sap.cds.handlers.AttachmentEventHandler; -import com.sap.cds.reflect.CdsEntity; -import com.sap.cds.service.ExtractionService; -import com.sap.cds.service.StartExtractionEventContext; -import com.sap.cds.services.request.UserInfo; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class AttachmentEventHandlerTest { - - private static final String ATTACHMENT_ID = "test-attachment-id"; - private static final String CONTENT_ID = "test-content-id"; - private static final String TENANT_ID = "test-tenant"; - private static final String FILE_NAME = "dummy_invoice.pdf"; - private static final String MIME_TYPE = "application/pdf"; - private static final String ENTITY_NAME = "test.Entity"; - - @Mock ExtractionService extractionService; - @Mock AttachmentCreateEventContext context; - @Mock UserInfo userInfo; - @Mock MediaData mediaData; - @Mock CdsEntity attachmentEntity; - - AttachmentEventHandler handler; - - @BeforeEach - void setUp() { - handler = new AttachmentEventHandler(extractionService); - when(context.getAttachmentIds()).thenReturn(Map.of(Attachments.ID, ATTACHMENT_ID)); - } - - @Test - void shouldEmitStartExtractionEvent() { - when(context.getContentId()).thenReturn(CONTENT_ID); - when(context.getUserInfo()).thenReturn(userInfo); - when(context.getData()).thenReturn(mediaData); - when(context.getAttachmentEntity()).thenReturn(attachmentEntity); - when(attachmentEntity.getQualifiedName()).thenReturn(ENTITY_NAME); - when(userInfo.getTenant()).thenReturn(TENANT_ID); - when(mediaData.getFileName()).thenReturn(FILE_NAME); - when(mediaData.getMimeType()).thenReturn(MIME_TYPE); - - handler.afterCreateAttachment(context); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(StartExtractionEventContext.class); - verify(extractionService).emit(captor.capture()); - - StartExtractionEventContext emitted = captor.getValue(); - assertThat(emitted.getAttachmentId()).isEqualTo(ATTACHMENT_ID); - assertThat(emitted.getContentId()).isEqualTo(CONTENT_ID); - assertThat(emitted.getTenantId()).isEqualTo(TENANT_ID); - assertThat(emitted.getFileName()).isEqualTo(FILE_NAME); - assertThat(emitted.getMimeType()).isEqualTo(MIME_TYPE); - assertThat(emitted.getAttachmentEntityName()).isEqualTo(ENTITY_NAME); - } - - @Test - void shouldNotEmitWhenAttachmentIdIsMissing() { - when(context.getAttachmentIds()).thenReturn(Map.of()); - - handler.afterCreateAttachment(context); - - verify(extractionService, never()).emit(any()); - } -} diff --git a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java b/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java index fd8c6dd..4bffc3f 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java @@ -10,7 +10,6 @@ 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.DocumentAiService_; -import com.sap.cds.handlers.AttachmentEventHandler; import com.sap.cds.handlers.DocumentSubmissionHandler; import com.sap.cds.service.DefaultDocumentAiProcessingService; import com.sap.cds.service.ExtractionService; @@ -64,7 +63,7 @@ void servicesRegistersExtractionService() { } @Test - void eventHandlersRegistersAttachmentAndDocumentSubmissionHandlers() { + void eventHandlersRegistersDocumentSubmissionHandler() { when(configurer.getCdsRuntime()).thenReturn(cdsRuntime); when(cdsRuntime.getServiceCatalog()).thenReturn(serviceCatalog); when(cdsRuntime.getEnvironment()).thenReturn(environment); @@ -85,8 +84,6 @@ void eventHandlersRegistersAttachmentAndDocumentSubmissionHandlers() { verify(configurer, atLeast(2)).eventHandler(captor.capture()); List handlers = captor.getAllValues(); - assertThat(handlers.stream().anyMatch(h -> h.getClass() == AttachmentEventHandler.class)) - .isTrue(); assertThat(handlers.stream().anyMatch(h -> h.getClass() == DocumentSubmissionHandler.class)) .isTrue(); } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index bcd3009..128a215 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -9,19 +9,13 @@ import static org.mockito.Mockito.*; import com.sap.cds.Result; -import com.sap.cds.Struct; -import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; import com.sap.cds.ql.cqn.CqnInsert; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.CqnUpdate; -import com.sap.cds.reflect.CdsElementNotFoundException; -import com.sap.cds.reflect.CdsEntity; -import com.sap.cds.reflect.CdsModel; import com.sap.cds.service.exceptions.IllegalStatusTransitionException; import com.sap.cds.service.model.ExtractionResult; import com.sap.cds.services.persistence.PersistenceService; -import com.sap.cds.services.runtime.CdsRuntime; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -29,7 +23,6 @@ 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; @@ -37,20 +30,15 @@ class ExtractionServiceImplTest { static final String TENANT_1 = "tenant-1"; - static final String ATT_123 = "att-123"; - static final String CNT_123 = "cnt-123"; + static final String SRC_DOC_ID = "src-doc-123"; 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"; - static final String ENTITY_NAME = "test.Attachments"; @Mock PersistenceService persistenceService; @Mock DocumentAiProcessingService documentAiProcessingService; @Mock Result insertResult; - @Mock CdsRuntime cdsRuntime; - @Mock CdsModel cdsModel; - @Mock CdsEntity cdsEntity; ExtractionServiceImpl extractionService; @@ -62,111 +50,74 @@ void setUp() { } @Test - void startExtractionDoesNothingWhenServiceUnavailable() { + void triggerExtractionCreatesJobAsPendingWhenServiceUnavailable() { + mockInsertDatabaseCalls(); when(documentAiProcessingService.isAvailable()).thenReturn(false); - extractionService.onStartExtraction(eventContext()); - verify(persistenceService, never()).run(any(CqnInsert.class)); + + ExtractionResult result = + extractionService.triggerExtraction( + SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); + assertThat(result.internalJobId()).isNotNull(); + verify(persistenceService).run(any(CqnInsert.class)); } @Test - void startExtractionCreatesOneJobWithCorrectFields() { - mockContentLookup(contentStream()); + void triggerExtractionCreatesJobWithCorrectFields() { + mockInsertDatabaseCalls(); mockAllDatabaseCalls(); - mockSuccessfulProcessing(); - extractionService.onStartExtraction(eventContext()); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); - ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(CqnInsert.class); + extractionService.triggerExtraction(SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + + com.sap.cds.Struct struct = null; + var insertCaptor = org.mockito.ArgumentCaptor.forClass(CqnInsert.class); verify(persistenceService, atLeastOnce()).run(insertCaptor.capture()); ExtractionJob inserted = - Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); - assertThat(inserted.getAttachmentId()).isEqualTo(ATT_123); + com.sap.cds.Struct.access(insertCaptor.getValue().entries().get(0)).as(ExtractionJob.class); + assertThat(inserted.getSourceDocumentId()).isEqualTo(SRC_DOC_ID); assertThat(inserted.getTenantId()).isEqualTo(TENANT_1); assertThat(inserted.getStatus()).isEqualTo(PENDING.name()); } @Test - void startExtractionStoresDocumentAiJobIdAndUpdatesStatusToSubmitted() { - Result statusResult = resultWithJobStatus(PENDING); - mockContentThenStatus(contentStream(), statusResult); - mockAllDatabaseCalls(); - mockSuccessfulProcessing(); - extractionService.onStartExtraction(eventContext()); - - ArgumentCaptor captor = ArgumentCaptor.forClass(CqnUpdate.class); - verify(persistenceService, times(1)).run(captor.capture()); - - ExtractionJob update = - Struct.access(captor.getValue().entries().get(0)).as(ExtractionJob.class); - assertThat(update.getStatus()).isEqualTo(SUBMITTED.name()); - assertThat(update.getDocumentAiJobId()).isEqualTo(DIE_JOB_ID); - } - - @Test - void startExtractionSkipsWhenAttachmentNotFound() { - mockContentLookup(null); - extractionService.onStartExtraction(eventContext()); - verify(persistenceService, never()).run(any(CqnInsert.class)); - } - - @Test - void startExtractionSkipsWhenContentStreamIsNull() { - mockContentLookupWithNullStream(); - extractionService.onStartExtraction(eventContext()); - verify(persistenceService, never()).run(any(CqnInsert.class)); - } - - @Test - void updateStatusWithSameStateDoesNotRunJobAgain() { - Result statusResult = resultWithJobStatus(SUBMITTED); - mockContentThenStatus(contentStream(), statusResult); + void triggerExtractionSubmitsDocumentAndUpdatesStatusToSubmitted() { mockInsertDatabaseCalls(); - mockSuccessfulProcessing(); + mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); + when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); - extractionService.onStartExtraction(eventContext()); + ExtractionResult result = + extractionService.triggerExtraction(SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); - verify(persistenceService, never()).run(any(CqnUpdate.class)); + assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); + assertThat(result.documentAiJobId()).isEqualTo(DIE_JOB_ID); + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); } @Test - void invalidTransitionIsLoggedAndNoStatusUpdateOccurs() { - Result statusResult = resultWithJobStatus(COMPLETED); - mockContentThenStatus(contentStream(), statusResult); + void triggerExtractionMarksJobFailedOnProcessingError() { mockInsertDatabaseCalls(); - mockSuccessfulProcessing(); - assertThrows( - IllegalStatusTransitionException.class, - () -> extractionService.onStartExtraction(eventContext())); - verify(persistenceService, never()).run(any(CqnUpdate.class)); - } - - @Test - void markJobAsFailedSucceedsWhenTransitionFromPendingToFailedIsValid() { - Result statusResult = resultWithJobStatus(PENDING); - mockContentThenStatus(contentStream(), statusResult); mockAllDatabaseCalls(); + Result statusResult = resultWithJobStatus(PENDING); + lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); doThrow(new RuntimeException("simulated failure")) .when(documentAiProcessingService) .processDocument(any(), any()); - extractionService.onStartExtraction(eventContext()); - verify(persistenceService, times(1)).run(any(CqnUpdate.class)); - } - - @Test - void triggerExtractionCreatesJobAsPendingWhenServiceUnavailable() { - mockInsertDatabaseCalls(); // job creation must succeed - when(documentAiProcessingService.isAvailable()).thenReturn(false); ExtractionResult result = - extractionService.triggerExtraction( - ATT_123, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction(SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); - assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); - assertThat(result.internalJobId()).isNotNull(); - verify(persistenceService).run(any(CqnInsert.class)); // job was created + assertThat(result.status()).isEqualTo(ExtractionResult.Status.FAILED); + verify(persistenceService, times(1)).run(any(CqnUpdate.class)); } @Test - void triggerExtractionMarksJobFailedOnInvalidStatusTransition() { + void triggerExtractionThrowsOnInvalidStatusTransition() { mockInsertDatabaseCalls(); mockAllDatabaseCalls(); Result statusResult = resultWithJobStatus(PENDING); @@ -179,112 +130,27 @@ void triggerExtractionMarksJobFailedOnInvalidStatusTransition() { IllegalStatusTransitionException.class, () -> extractionService.triggerExtraction( - ATT_123, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1)); + SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1)); verify(persistenceService, never()).run(any(CqnUpdate.class)); } @Test - void triggerExtractionSubmitsDocumentAndUpdatesStatus() { + void updateStatusWithSameStateSkipsUpdate() { mockInsertDatabaseCalls(); - mockAllDatabaseCalls(); - Result statusResult = resultWithJobStatus(PENDING); + Result statusResult = resultWithJobStatus(SUBMITTED); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); - extractionService.triggerExtraction(ATT_123, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); - - verify(persistenceService, times(1)).run(any(CqnUpdate.class)); - } - - @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()); - - extractionService.triggerExtraction(ATT_123, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction(SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); - verify(persistenceService, times(1)).run(any(CqnUpdate.class)); - } - - private StartExtractionEventContext eventContext() { - StartExtractionEventContext ctx = mock(StartExtractionEventContext.class); - lenient().when(ctx.getAttachmentId()).thenReturn(ATT_123); - lenient().when(ctx.getTenantId()).thenReturn(TENANT_1); - lenient().when(ctx.getContentId()).thenReturn(CNT_123); - lenient().when(ctx.getFileName()).thenReturn(TEST_PDF); - lenient().when(ctx.getMimeType()).thenReturn(CONTENT_TYPE); - lenient().when(ctx.getAttachmentEntityName()).thenReturn(ENTITY_NAME); - lenient().when(ctx.getCdsRuntime()).thenReturn(cdsRuntime); - return ctx; + verify(persistenceService, never()).run(any(CqnUpdate.class)); } private InputStream contentStream() { return new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8)); } - private void mockEntityLookup() { - when(cdsRuntime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getEntity(ENTITY_NAME)).thenReturn(cdsEntity); - when(cdsEntity.getQualifiedName()).thenReturn(ENTITY_NAME); - doThrow(CdsElementNotFoundException.class).when(cdsEntity).getTargetOf(any()); - } - - private void mockContentThenStatus(InputStream content, Result statusResult) { - mockEntityLookup(); - Attachments attachment = Attachments.create(); - attachment.setContentId(CNT_123); - attachment.setContent(content); - - Result contentResult = mock(Result.class); - lenient().when(contentResult.rowCount()).thenReturn(1L); - lenient().when(contentResult.single(Attachments.class)).thenReturn(attachment); - lenient().when(contentResult.first(Attachments.class)).thenReturn(Optional.of(attachment)); - - lenient() - .when(persistenceService.run(any(CqnSelect.class))) - .thenReturn(contentResult, statusResult); - } - - private void mockContentLookup(InputStream content) { - mockEntityLookup(); - Attachments attachment = Attachments.create(); - attachment.setContentId(CNT_123); - attachment.setContent(content); - - Result contentResult = mock(Result.class); - lenient().when(contentResult.rowCount()).thenReturn(content != null ? 1L : 0L); - lenient().when(contentResult.single(Attachments.class)).thenReturn(attachment); - lenient() - .when(contentResult.first(Attachments.class)) - .thenReturn(content != null ? Optional.of(attachment) : Optional.empty()); - lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(contentResult); - } - - private void mockContentLookupWithNullStream() { - mockEntityLookup(); - Attachments attachment = Attachments.create(); - attachment.setContentId(CNT_123); - attachment.setContent(null); - - Result contentResult = mock(Result.class); - lenient().when(contentResult.rowCount()).thenReturn(1L); - lenient().when(contentResult.single(Attachments.class)).thenReturn(attachment); - lenient().when(contentResult.first(Attachments.class)).thenReturn(Optional.of(attachment)); - lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(contentResult); - } - - private void mockSuccessfulProcessing() { - lenient() - .when(documentAiProcessingService.processDocument(any(), any())) - .thenReturn(DIE_JOB_ID); - } - private void mockInsertDatabaseCalls() { ExtractionJob createdJob = ExtractionJob.create(); createdJob.setId("test-job-id"); From 20cdcd8c2580d2fbf83bfa554b0e669869023a85 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:48:38 +0200 Subject: [PATCH 44/70] refactor: rename registration files / classes --- ...va => DocumentAiServiceConfiguration.java} | 4 ++-- ...s.services.runtime.CdsRuntimeConfiguration | 2 +- ...> DocumentAiServiceConfigurationTest.java} | 24 ++++++------------- .../service/ExtractionServiceImplTest.java | 13 ++++++---- 4 files changed, 18 insertions(+), 25 deletions(-) rename sap-document-ai/src/main/java/com/sap/cds/configuration/{AttachmentEventHandlerRegistration.java => DocumentAiServiceConfiguration.java} (96%) rename sap-document-ai/src/test/java/com/sap/cds/configuration/{AttachmentEventHandlerRegistrationTest.java => DocumentAiServiceConfigurationTest.java} (79%) diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java similarity index 96% rename from sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java rename to sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java index 869068a..1ec0775 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/AttachmentEventHandlerRegistration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java @@ -25,10 +25,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class AttachmentEventHandlerRegistration implements CdsRuntimeConfiguration { +public class DocumentAiServiceConfiguration implements CdsRuntimeConfiguration { private static final Logger logger = - LoggerFactory.getLogger(AttachmentEventHandlerRegistration.class); + LoggerFactory.getLogger(DocumentAiServiceConfiguration.class); private ExtractionServiceImpl extractionService; diff --git a/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration index e34fdfa..9362562 100644 --- a/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration +++ b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -1 +1 @@ -com.sap.cds.configuration.AttachmentEventHandlerRegistration +com.sap.cds.configuration.DocumentAiServiceConfiguration diff --git a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java b/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java similarity index 79% rename from sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java rename to sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java index 4bffc3f..bac17a0 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/configuration/AttachmentEventHandlerRegistrationTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java @@ -12,21 +12,18 @@ import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentAiService_; import com.sap.cds.handlers.DocumentSubmissionHandler; import com.sap.cds.service.DefaultDocumentAiProcessingService; -import com.sap.cds.service.ExtractionService; import com.sap.cds.service.ExtractionServiceImpl; import com.sap.cds.service.documentai.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.outbox.OutboxService; 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.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -36,21 +33,20 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) -class AttachmentEventHandlerRegistrationTest { +class DocumentAiServiceConfigurationTest { @Mock CdsRuntimeConfigurer configurer; @Mock CdsRuntime cdsRuntime; @Mock ServiceCatalog serviceCatalog; @Mock PersistenceService persistenceService; - @Mock OutboxService outboxService; @Mock DocumentAiService documentAiService; @Mock CdsEnvironment environment; - AttachmentEventHandlerRegistration registration; + DocumentAiServiceConfiguration registration; @BeforeEach void setUp() { - registration = new AttachmentEventHandlerRegistration(); + registration = new DocumentAiServiceConfiguration(); } @Test @@ -70,29 +66,23 @@ void eventHandlersRegistersDocumentSubmissionHandler() { when(environment.getServiceBindings()).thenReturn(Stream.empty()); when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) .thenReturn(persistenceService); - when(serviceCatalog.getService(OutboxService.class, OutboxService.PERSISTENT_UNORDERED_NAME)) - .thenReturn(outboxService); when(serviceCatalog.getService(DocumentAiService.class, DocumentAiService_.CDS_NAME)) .thenReturn(documentAiService); - ExtractionService outboxed = mock(ExtractionService.class); - doReturn(outboxed).when(outboxService).outboxed(any(ExtractionService.class)); registration.services(configurer); registration.eventHandlers(configurer); ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); - verify(configurer, atLeast(2)).eventHandler(captor.capture()); + verify(configurer, times(1)).eventHandler(captor.capture()); - List handlers = captor.getAllValues(); - assertThat(handlers.stream().anyMatch(h -> h.getClass() == DocumentSubmissionHandler.class)) - .isTrue(); + assertThat(captor.getValue()).isInstanceOf(DocumentSubmissionHandler.class); } @Test void buildDocumentAi_noBindingFound_returnsNull() { when(environment.getServiceBindings()).thenReturn(Stream.empty()); - DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); + DocumentAiClient result = DocumentAiServiceConfiguration.buildDocumentAi(environment); assertThat(result).isNull(); } @@ -115,7 +105,7 @@ void buildDocumentAi_bindingFound_destinationCreationFails_returnsNull() { loader.when(ServiceBindingDestinationLoader::defaultLoaderChain).thenReturn(loaderMock); when(loaderMock.getDestination(any())).thenThrow(new RuntimeException("destination fail")); - DocumentAiClient result = AttachmentEventHandlerRegistration.buildDocumentAi(environment); + DocumentAiClient result = DocumentAiServiceConfiguration.buildDocumentAi(environment); assertThat(result).isNull(); } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 128a215..cd498ad 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -71,9 +71,9 @@ void triggerExtractionCreatesJobWithCorrectFields() { lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); - extractionService.triggerExtraction(SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction( + SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); - com.sap.cds.Struct struct = null; var insertCaptor = org.mockito.ArgumentCaptor.forClass(CqnInsert.class); verify(persistenceService, atLeastOnce()).run(insertCaptor.capture()); ExtractionJob inserted = @@ -92,7 +92,8 @@ void triggerExtractionSubmitsDocumentAndUpdatesStatusToSubmitted() { when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); ExtractionResult result = - extractionService.triggerExtraction(SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction( + SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); assertThat(result.documentAiJobId()).isEqualTo(DIE_JOB_ID); @@ -110,7 +111,8 @@ void triggerExtractionMarksJobFailedOnProcessingError() { .processDocument(any(), any()); ExtractionResult result = - extractionService.triggerExtraction(SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction( + SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); assertThat(result.status()).isEqualTo(ExtractionResult.Status.FAILED); verify(persistenceService, times(1)).run(any(CqnUpdate.class)); @@ -142,7 +144,8 @@ void updateStatusWithSameStateSkipsUpdate() { lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); - extractionService.triggerExtraction(SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction( + SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); verify(persistenceService, never()).run(any(CqnUpdate.class)); } From af741078656bf4dea0a244085cc3cd23144ac05e Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:00:44 +0200 Subject: [PATCH 45/70] feat: implement programmatic way of using the plugin --- bookshop/app/admin-books/fiori-service.cds | 15 ++ bookshop/srv/admin-service.cds | 8 +- .../handlers/DocumentExtractionHandler.java | 120 +++++++++ .../DocumentAiServiceConfiguration.java | 6 +- .../handlers/DocumentSubmissionHandler.java | 158 ++---------- .../sap/cds/service/ExtractionService.java | 8 +- .../cds/service/ExtractionServiceImpl.java | 30 +-- .../exceptions/SourceDocumentException.java | 27 -- .../sap-document-ai/document-ai-service.cds | 17 +- .../sap-document-ai/extraction-job.cds | 7 - .../cds/DocumentSubmissionHandlerTest.java | 243 +++--------------- .../DocumentAiServiceConfigurationTest.java | 5 - .../service/ExtractionServiceImplTest.java | 20 +- .../service/exceptions/ExceptionsTest.java | 15 -- 14 files changed, 214 insertions(+), 465 deletions(-) create mode 100644 bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java delete mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/SourceDocumentException.java diff --git a/bookshop/app/admin-books/fiori-service.cds b/bookshop/app/admin-books/fiori-service.cds index 36fa090..0bff5da 100644 --- a/bookshop/app/admin-books/fiori-service.cds +++ b/bookshop/app/admin-books/fiori-service.cds @@ -111,3 +111,18 @@ annotate AdminService.Books with { 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/bookshop/srv/admin-service.cds b/bookshop/srv/admin-service.cds index 9ae8bbc..540f5ba 100644 --- a/bookshop/srv/admin-service.cds +++ b/bookshop/srv/admin-service.cds @@ -1,6 +1,10 @@ using {sap.capire.bookshop as my} from '../db/schema'; service AdminService @(requires: 'admin') { - entity Books as projection on my.Books; + + 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/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java b/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java new file mode 100644 index 0000000..fa65db0 --- /dev/null +++ b/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java @@ -0,0 +1,120 @@ +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.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 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 sap-document-ai plugin is configured."); + } + + DocumentExtraction event = DocumentExtraction.create(); + event.setFileName(attachment.getFileName()); + event.setMimeType(attachment.getMimeType()); + event.setContent(attachment.getContent()); + + 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/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java index 1ec0775..82e7818 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java @@ -3,8 +3,6 @@ */ package com.sap.cds.configuration; -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.DocumentAiService_; import com.sap.cds.handlers.DocumentSubmissionHandler; import com.sap.cds.service.DefaultDocumentAiProcessingService; import com.sap.cds.service.DocumentAiProcessingService; @@ -55,8 +53,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { // framework-managed dependency PersistenceService persistenceService = serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - DocumentAiService documentAiService = - serviceCatalog.getService(DocumentAiService.class, DocumentAiService_.CDS_NAME); // internal DocumentAiClient documentAiClient = buildDocumentAi(runtime.getEnvironment()); @@ -65,7 +61,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { extractionService.init(persistenceService, documentAiProcessingService); - configurer.eventHandler(new DocumentSubmissionHandler(extractionService, documentAiService)); + configurer.eventHandler(new DocumentSubmissionHandler(extractionService)); } static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java index 409820f..4de0053 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java @@ -3,172 +3,46 @@ */ package com.sap.cds.handlers; -import static com.sap.cds.service.ExtractionService.EVENT_START_EXTRACTION; - -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.*; -import com.sap.cds.ql.Select; +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.service.ExtractionService; -import com.sap.cds.service.exceptions.SourceDocumentException; import com.sap.cds.service.model.ExtractionResult; -import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.ApplicationService; 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 java.io.InputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(DocumentAiService_.CDS_NAME) +@ServiceName(value = "*", type = ApplicationService.class) public class DocumentSubmissionHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(DocumentSubmissionHandler.class); private final ExtractionService extractionService; - private final DocumentAiService documentAiService; - public DocumentSubmissionHandler( - ExtractionService extractionService, DocumentAiService documentAiService) { + public DocumentSubmissionHandler(ExtractionService extractionService) { this.extractionService = extractionService; - this.documentAiService = documentAiService; } - @On(event = EVENT_START_EXTRACTION) - public void onStartExtraction(StartExtractionContext context) { - context.setCompleted(); - - String sourceDocumentId = context.getSourceDocumentId(); + @On(event = DocumentExtractionContext.CDS_NAME) + public void onDocumentExtraction(DocumentExtractionContext context) { + DocumentExtraction event = context.getData(); String tenantId = context.getUserInfo().getTenant(); logger.info( - "[sap-document-ai] startExtraction action called for sourceDocumentId={}", - sourceDocumentId); - - SourceDocument document = - documentAiService - .run( - Select.from(SourceDocument_.class) - .columns( - SourceDocument.ID, - SourceDocument.FILE_NAME, - SourceDocument.MIME_TYPE, - SourceDocument.CONTENT) - .byId(sourceDocumentId)) - .first(SourceDocument.class) - .orElse(null); - - if (document == null) { - throw new SourceDocumentException.NotFound(sourceDocumentId); - } + "[sap-document-ai] DocumentExtraction event received, fileName={}", event.getFileName()); - InputStream content = document.getContent(); - if (content == null) { - throw new SourceDocumentException.ContentMissing(sourceDocumentId); - } - - ExtractionResult extraction = + ExtractionResult result = extractionService.triggerExtraction( - sourceDocumentId, document.getFileName(), document.getMimeType(), content, tenantId); - - ExtractionJob result = ExtractionJob.create(); - result.setId(extraction.internalJobId()); - result.setSourceDocumentId(sourceDocumentId); - context.setResult(result); - } - - @After(event = CqnService.EVENT_UPDATE, entity = SourceDocument_.CDS_NAME) - public void afterContentUpload(CdsUpdateEventContext context) { - - // Trigger extraction only when document content was updated - boolean contentUpdated = - context.getCqn().entries().stream() - .anyMatch(entry -> entry.containsKey(SourceDocument.CONTENT)); + event.getFileName(), event.getMimeType(), event.getContent(), tenantId); - if (!contentUpdated) { - logger.debug( - "[sap-document-ai] SourceDocument UPDATE contained no content changes, skipping extraction"); - return; + switch (result.status()) { + case FAILED -> + logger.error("[sap-document-ai] Extraction failed for fileName={}", event.getFileName()); + case PENDING -> logger.warn("[sap-document-ai] Document AI unavailable, left as PENDING"); } - List sourceDocumentIds = - context.getResult().listOf(SourceDocument.class).stream() - .map(SourceDocument::getId) - .filter(Objects::nonNull) - .distinct() - .toList(); - - if (sourceDocumentIds.isEmpty()) { - logger.debug("[sap-document-ai] No SourceDocument IDs in result, skipping extraction"); - return; - } - - String tenantId = context.getUserInfo().getTenant(); - List failedIds = new ArrayList<>(); - - List documents = - documentAiService - .run( - Select.from(SourceDocument_.class) - .columns( - SourceDocument.ID, - SourceDocument.FILE_NAME, - SourceDocument.MIME_TYPE, - SourceDocument.CONTENT) - .where(d -> d.get(SourceDocument.ID).in(sourceDocumentIds))) - .listOf(SourceDocument.class); - - for (SourceDocument document : documents) { - InputStream content = document.getContent(); - String sourceDocumentId = document.getId(); - - if (content == null) { - logger.warn( - "[sap-document-ai] Content is null for sourceDocumentId={}, skipping extraction", - sourceDocumentId); - failedIds.add(sourceDocumentId); - continue; - } - - try { - logger.info( - "[sap-document-ai] Content uploaded for sourceDocumentId={}, triggering extraction", - sourceDocumentId); - ExtractionResult extraction = - extractionService.triggerExtraction( - sourceDocumentId, - document.getFileName(), - document.getMimeType(), - content, - tenantId); - switch (extraction.status()) { - case FAILED -> { - logger.error( - "[sap-document-ai] Extraction failed for sourceDocumentId={}", sourceDocumentId); - failedIds.add(sourceDocumentId); - } - case PENDING -> - logger.warn( - "[sap-document-ai] Document AI unavailable, sourceDocumentId={} left as PENDING", - sourceDocumentId); - default -> {} - } - } catch (Exception e) { - logger.error( - "[sap-document-ai] Extraction failed for sourceDocumentId={}", sourceDocumentId, e); - failedIds.add(sourceDocumentId); - } - } - - if (!failedIds.isEmpty()) { - logger.error( - "[sap-document-ai] Extraction failed for {} of {} document(s): {}", - failedIds.size(), - documents.size(), - failedIds); - } + context.setCompleted(); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index fd4d2e0..7100447 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -11,12 +11,6 @@ public interface ExtractionService extends Service { String NAME = "ExtractionService"; - String EVENT_START_EXTRACTION = "startExtraction"; - ExtractionResult triggerExtraction( - String sourceDocumentId, - String fileName, - String mimeType, - InputStream content, - String tenantId); + String fileName, String mimeType, InputStream content, String tenantId); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index b788504..b673265 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -42,17 +42,13 @@ public void init( @Override public ExtractionResult triggerExtraction( - String sourceDocumentId, - String fileName, - String mimeType, - InputStream content, - String tenantId) { + String fileName, String mimeType, InputStream content, String tenantId) { logger.info( - "[sap-document-ai] Direct extraction triggered for sourceDocumentId={}, tenantId={}", - sourceDocumentId, + "[sap-document-ai] Direct extraction triggered for fileName={}, tenantId={}", + fileName, tenantId); // create pending job - String jobId = createExtractionJob(sourceDocumentId, tenantId); + String jobId = createExtractionJob(tenantId); // check for availability of the service. if (!documentAiProcessingService.isAvailable()) { @@ -62,13 +58,11 @@ public ExtractionResult triggerExtraction( } DocumentInput documentInput = new DocumentInput(fileName, mimeType, content); - ExtractionResult extractionResult = - performExtraction(jobId, sourceDocumentId, documentInput, tenantId); - return extractionResult; + return performExtraction(jobId, fileName, documentInput, tenantId); } private ExtractionResult performExtraction( - String jobId, String sourceId, DocumentInput documentInput, String tenantId) { + String jobId, String fileName, DocumentInput documentInput, String tenantId) { try { String documentAiJobId = documentAiProcessingService.processDocument(jobId, documentInput); updateExtractionJob(jobId, SUBMITTED, documentAiJobId); @@ -81,8 +75,8 @@ private ExtractionResult performExtraction( throw e; } catch (Exception e) { // example : PROCESSING -> FAILED logger.error( - "[sap-document-ai] Processing failed for sourceId={}, tenantId={}", - sourceId, + "[sap-document-ai] Processing failed for fileName={}, tenantId={}", + fileName, tenantId, e); markJobAsFailed(jobId); @@ -98,18 +92,14 @@ private void markJobAsFailed(String jobId) { } } - private String createExtractionJob(String sourceDocumentId, String tenantId) { + private String createExtractionJob(String tenantId) { ExtractionJob job = ExtractionJob.create(); - job.setSourceDocumentId(sourceDocumentId); 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, sourceId={}, jobId={}", - sourceDocumentId, - jobId); + logger.info("[sap-document-ai] ExtractionJob created with status=PENDING, jobId={}", jobId); return jobId; } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/SourceDocumentException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/SourceDocumentException.java deleted file mode 100644 index efb080d..0000000 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/SourceDocumentException.java +++ /dev/null @@ -1,27 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds.service.exceptions; - -import com.sap.cds.services.ErrorStatus; -import com.sap.cds.services.ErrorStatuses; -import com.sap.cds.services.ServiceException; - -public class SourceDocumentException extends ServiceException { - - protected SourceDocumentException(ErrorStatus status, String message) { - super(status, message); - } - - public static class NotFound extends SourceDocumentException { - public NotFound(String sourceDocumentId) { - super(ErrorStatuses.NOT_FOUND, "SourceDocument not found: " + sourceDocumentId); - } - } - - public static class ContentMissing extends SourceDocumentException { - public ContentMissing(String sourceDocumentId) { - super(ErrorStatuses.BAD_REQUEST, "No content uploaded for: " + sourceDocumentId); - } - } -} diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds index a161cbf..3fa5f33 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds @@ -1,16 +1,9 @@ namespace sap.document.ai; -using {sap.document.ai as ai} from './extraction-job'; - service DocumentAiService { - entity SourceDocument as projection on ai.SourceDocument; - - @readonly - entity ExtractionJob as projection on ai.ExtractionJob - excluding { - tenantId, - documentAiJobId - }; - - action startExtraction(sourceDocumentId : UUID) returns ExtractionJob; + event DocumentExtraction { + fileName : String; + mimeType : String @Core.IsMediaType; + content : LargeBinary @Core.MediaType: mimeType; + } } diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index 0b5370c..f9fdd00 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -5,14 +5,7 @@ using { managed } from '@sap/cds/common'; -entity SourceDocument : cuid { - fileName : String; - mimeType : String @Core.IsMediaType; - content : LargeBinary @Core.MediaType: mimeType; -} - entity ExtractionJob : cuid, managed { - sourceDocument : Association to SourceDocument; status : String; tenantId : String; documentAiJobId : String; diff --git a/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java index f2b460a..e351127 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java @@ -3,25 +3,18 @@ */ package com.sap.cds; -import static org.junit.jupiter.api.Assertions.assertThrows; 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.DocumentAiService; -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.SourceDocument; -import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.StartExtractionContext; +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.handlers.DocumentSubmissionHandler; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.service.ExtractionService; import com.sap.cds.service.model.ExtractionResult; -import com.sap.cds.services.cds.CdsUpdateEventContext; import com.sap.cds.services.request.UserInfo; import java.io.ByteArrayInputStream; import java.io.InputStream; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -31,235 +24,67 @@ @ExtendWith(MockitoExtension.class) class DocumentSubmissionHandlerTest { - private static final String SOURCE_DOCUMENT_ID = "src-doc-123"; 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 DocumentAiService documentAiService; - @Mock CdsUpdateEventContext context; - @Mock StartExtractionContext startExtractionContext; + @Mock DocumentExtractionContext eventContext; @Mock UserInfo userInfo; - @Mock Result selectResult; - @Mock Result updateResult; - @Mock CqnUpdate cqnUpdate; DocumentSubmissionHandler handler; @BeforeEach void setUp() { - handler = new DocumentSubmissionHandler(extractionService, documentAiService); + handler = new DocumentSubmissionHandler(extractionService); } - @Test - void onStartExtraction_returnsExtractionJobOnSuccess() { - when(startExtractionContext.getSourceDocumentId()).thenReturn(SOURCE_DOCUMENT_ID); - when(startExtractionContext.getUserInfo()).thenReturn(userInfo); - when(userInfo.getTenant()).thenReturn(TENANT_ID); - when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); - - SourceDocument doc = - createDocument(SOURCE_DOCUMENT_ID, new ByteArrayInputStream("pdf-bytes".getBytes())); - when(selectResult.first(SourceDocument.class)).thenReturn(java.util.Optional.of(doc)); - - ExtractionResult extraction = - new ExtractionResult("job-123", ExtractionResult.Status.SUCCESS, "dai-job-456"); - when(extractionService.triggerExtraction( - eq(SOURCE_DOCUMENT_ID), - eq(FILE_NAME), - eq(MIME_TYPE), - any(InputStream.class), - eq(TENANT_ID))) - .thenReturn(extraction); - - handler.onStartExtraction(startExtractionContext); - - verify(startExtractionContext).setResult(argThat(job -> "job-123".equals(job.getId()))); - } - - @Test - void onStartExtraction_throwsNotFoundWhenDocumentNotFound() { - when(startExtractionContext.getSourceDocumentId()).thenReturn(SOURCE_DOCUMENT_ID); - when(startExtractionContext.getUserInfo()).thenReturn(userInfo); - when(userInfo.getTenant()).thenReturn(TENANT_ID); - when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); - when(selectResult.first(SourceDocument.class)).thenReturn(java.util.Optional.empty()); - - assertThrows( - com.sap.cds.service.exceptions.SourceDocumentException.NotFound.class, - () -> handler.onStartExtraction(startExtractionContext)); + 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 onStartExtraction_throwsContentMissingWhenContentIsNull() { - when(startExtractionContext.getSourceDocumentId()).thenReturn(SOURCE_DOCUMENT_ID); - when(startExtractionContext.getUserInfo()).thenReturn(userInfo); + void onDocumentExtraction_triggersExtraction() { + when(eventContext.getUserInfo()).thenReturn(userInfo); when(userInfo.getTenant()).thenReturn(TENANT_ID); - when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); + when(eventContext.getData()).thenReturn(createEvent()); + when(extractionService.triggerExtraction(any(), any(), any(), any())) + .thenReturn( + new ExtractionResult("job-123", ExtractionResult.Status.SUCCESS, "dai-job-456")); - SourceDocument doc = createDocument(SOURCE_DOCUMENT_ID, null); - when(selectResult.first(SourceDocument.class)).thenReturn(java.util.Optional.of(doc)); - - assertThrows( - com.sap.cds.service.exceptions.SourceDocumentException.ContentMissing.class, - () -> handler.onStartExtraction(startExtractionContext)); - } - - @Test - void afterContentUpload_triggersExtraction() { - when(cqnUpdate.entries()).thenReturn(List.of(entryWithContent(SOURCE_DOCUMENT_ID))); - when(context.getCqn()).thenReturn(cqnUpdate); - mockUserInfo(); - - SourceDocument idOnly = SourceDocument.create(); - idOnly.setId(SOURCE_DOCUMENT_ID); - when(context.getResult()).thenReturn(updateResult); - when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of(idOnly)); - - SourceDocument doc = - createDocument(SOURCE_DOCUMENT_ID, new ByteArrayInputStream("pdf-bytes".getBytes())); - when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); - when(selectResult.listOf(SourceDocument.class)).thenReturn(List.of(doc)); - - handler.afterContentUpload(context); + handler.onDocumentExtraction(eventContext); verify(extractionService) - .triggerExtraction( - eq(SOURCE_DOCUMENT_ID), - eq(FILE_NAME), - eq(MIME_TYPE), - any(InputStream.class), - eq(TENANT_ID)); - } - - @Test - void afterContentUpload_skipsWhenNoContentInUpdate() { - when(cqnUpdate.entries()).thenReturn(List.of(Map.of(SourceDocument.ID, SOURCE_DOCUMENT_ID))); - when(context.getCqn()).thenReturn(cqnUpdate); - - handler.afterContentUpload(context); - - verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); - verify(documentAiService, never()).run(any(CqnSelect.class)); - } - - @Test - void afterContentUpload_skipsWhenIdMissing() { - when(cqnUpdate.entries()).thenReturn(List.of(Map.of())); - when(context.getCqn()).thenReturn(cqnUpdate); - - handler.afterContentUpload(context); - - verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); - verify(documentAiService, never()).run(any(CqnSelect.class)); - } - - @Test - void afterContentUpload_skipsWhenResultReturnsNoIds() { - Map entry = new HashMap<>(); - entry.put(SourceDocument.CONTENT, new ByteArrayInputStream("pdf-bytes".getBytes())); - when(cqnUpdate.entries()).thenReturn(List.of(entry)); - when(context.getCqn()).thenReturn(cqnUpdate); - - when(context.getResult()).thenReturn(updateResult); - when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of()); - - handler.afterContentUpload(context); - - verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); - verify(documentAiService, never()).run(any(CqnSelect.class)); - } - - @Test - void afterContentUpload_skipsWhenDocumentNotFound() { - when(cqnUpdate.entries()).thenReturn(List.of(entryWithContent(SOURCE_DOCUMENT_ID))); - when(context.getCqn()).thenReturn(cqnUpdate); - mockUserInfo(); - - SourceDocument idOnly = SourceDocument.create(); - idOnly.setId(SOURCE_DOCUMENT_ID); - when(context.getResult()).thenReturn(updateResult); - when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of(idOnly)); - - when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); - when(selectResult.listOf(SourceDocument.class)).thenReturn(List.of()); - - handler.afterContentUpload(context); - - verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); + .triggerExtraction(eq(FILE_NAME), eq(MIME_TYPE), any(InputStream.class), eq(TENANT_ID)); } @Test - void afterContentUpload_skipsWhenContentIsNull() { - when(cqnUpdate.entries()).thenReturn(List.of(entryWithContent(SOURCE_DOCUMENT_ID))); - when(context.getCqn()).thenReturn(cqnUpdate); - mockUserInfo(); - - SourceDocument idOnly = SourceDocument.create(); - idOnly.setId(SOURCE_DOCUMENT_ID); - when(context.getResult()).thenReturn(updateResult); - when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of(idOnly)); - - SourceDocument doc = createDocument(SOURCE_DOCUMENT_ID, null); - when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); - when(selectResult.listOf(SourceDocument.class)).thenReturn(List.of(doc)); + 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())) + .thenReturn(new ExtractionResult(null, ExtractionResult.Status.PENDING, null)); - handler.afterContentUpload(context); + handler.onDocumentExtraction(eventContext); - verify(extractionService, never()).triggerExtraction(any(), any(), any(), any(), any()); + verify(extractionService).triggerExtraction(any(), any(), any(), any()); } @Test - void afterContentUpload_continuesAndCollectsFailedIds() { - when(cqnUpdate.entries()) - .thenReturn(List.of(entryWithContent("src-1"), entryWithContent("src-2"))); - when(context.getCqn()).thenReturn(cqnUpdate); - mockUserInfo(); - - SourceDocument id1 = SourceDocument.create(); - id1.setId("src-1"); - SourceDocument id2 = SourceDocument.create(); - id2.setId("src-2"); - when(context.getResult()).thenReturn(updateResult); - when(updateResult.listOf(SourceDocument.class)).thenReturn(List.of(id1, id2)); - - SourceDocument doc1 = createDocument("src-1", new ByteArrayInputStream("pdf-bytes".getBytes())); - SourceDocument doc2 = createDocument("src-2", new ByteArrayInputStream("pdf-bytes".getBytes())); - when(documentAiService.run(any(CqnSelect.class))).thenReturn(selectResult); - when(selectResult.listOf(SourceDocument.class)).thenReturn(List.of(doc1, doc2)); - doThrow(new RuntimeException("DIE unavailable")) - .when(extractionService) - .triggerExtraction(eq("src-1"), any(), any(), any(), any()); - - handler.afterContentUpload(context); - - verify(extractionService) - .triggerExtraction( - eq("src-1"), eq(FILE_NAME), eq(MIME_TYPE), any(InputStream.class), eq(TENANT_ID)); - verify(extractionService) - .triggerExtraction( - eq("src-2"), eq(FILE_NAME), eq(MIME_TYPE), any(InputStream.class), eq(TENANT_ID)); - } - - private void mockUserInfo() { - when(context.getUserInfo()).thenReturn(userInfo); + 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())) + .thenReturn(new ExtractionResult("job-123", ExtractionResult.Status.FAILED, null)); - private Map entryWithContent(String id) { - Map entry = new HashMap<>(); - entry.put(SourceDocument.ID, id); - entry.put(SourceDocument.CONTENT, new ByteArrayInputStream("pdf-bytes".getBytes())); - return entry; - } + handler.onDocumentExtraction(eventContext); - private SourceDocument createDocument(String id, InputStream content) { - SourceDocument doc = SourceDocument.create(); - doc.setId(id); - doc.setFileName(FILE_NAME); - doc.setMimeType(MIME_TYPE); - doc.setContent(content); - return doc; + verify(extractionService).triggerExtraction(any(), any(), any(), any()); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java b/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java index bac17a0..471cca4 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java @@ -8,8 +8,6 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; -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.DocumentAiService_; import com.sap.cds.handlers.DocumentSubmissionHandler; import com.sap.cds.service.DefaultDocumentAiProcessingService; import com.sap.cds.service.ExtractionServiceImpl; @@ -39,7 +37,6 @@ class DocumentAiServiceConfigurationTest { @Mock CdsRuntime cdsRuntime; @Mock ServiceCatalog serviceCatalog; @Mock PersistenceService persistenceService; - @Mock DocumentAiService documentAiService; @Mock CdsEnvironment environment; DocumentAiServiceConfiguration registration; @@ -66,8 +63,6 @@ void eventHandlersRegistersDocumentSubmissionHandler() { when(environment.getServiceBindings()).thenReturn(Stream.empty()); when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) .thenReturn(persistenceService); - when(serviceCatalog.getService(DocumentAiService.class, DocumentAiService_.CDS_NAME)) - .thenReturn(documentAiService); registration.services(configurer); registration.eventHandlers(configurer); diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index cd498ad..44d4396 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -30,7 +30,6 @@ class ExtractionServiceImplTest { static final String TENANT_1 = "tenant-1"; - static final String SRC_DOC_ID = "src-doc-123"; static final String DIE_JOB_ID = "die-job-123"; static final String TEST_PDF = "test.pdf"; static final String CONTENT_TYPE = "application/pdf"; @@ -55,8 +54,7 @@ void triggerExtractionCreatesJobAsPendingWhenServiceUnavailable() { when(documentAiProcessingService.isAvailable()).thenReturn(false); ExtractionResult result = - extractionService.triggerExtraction( - SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); assertThat(result.internalJobId()).isNotNull(); @@ -71,14 +69,12 @@ void triggerExtractionCreatesJobWithCorrectFields() { lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); - extractionService.triggerExtraction( - SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), 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.getSourceDocumentId()).isEqualTo(SRC_DOC_ID); assertThat(inserted.getTenantId()).isEqualTo(TENANT_1); assertThat(inserted.getStatus()).isEqualTo(PENDING.name()); } @@ -92,8 +88,7 @@ void triggerExtractionSubmitsDocumentAndUpdatesStatusToSubmitted() { when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); ExtractionResult result = - extractionService.triggerExtraction( - SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); assertThat(result.documentAiJobId()).isEqualTo(DIE_JOB_ID); @@ -111,8 +106,7 @@ void triggerExtractionMarksJobFailedOnProcessingError() { .processDocument(any(), any()); ExtractionResult result = - extractionService.triggerExtraction( - SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); assertThat(result.status()).isEqualTo(ExtractionResult.Status.FAILED); verify(persistenceService, times(1)).run(any(CqnUpdate.class)); @@ -131,8 +125,7 @@ void triggerExtractionThrowsOnInvalidStatusTransition() { assertThrows( IllegalStatusTransitionException.class, () -> - extractionService.triggerExtraction( - SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1)); + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1)); verify(persistenceService, never()).run(any(CqnUpdate.class)); } @@ -144,8 +137,7 @@ void updateStatusWithSameStateSkipsUpdate() { lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); - extractionService.triggerExtraction( - SRC_DOC_ID, TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); verify(persistenceService, never()).run(any(CqnUpdate.class)); } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java index 4499b9c..81a0c4f 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java @@ -47,19 +47,4 @@ void illegalStatusTransitionExceptionContainsMessage() { assertThat(ex.getMessage()).isEqualTo(message); } - - @Test - void sourceDocumentNotFoundExceptionContainsSourceDocumentId() { - SourceDocumentException.NotFound ex = new SourceDocumentException.NotFound("src-123"); - - assertThat(ex.getMessage()).contains("src-123"); - } - - @Test - void sourceDocumentContentMissingExceptionContainsSourceDocumentId() { - SourceDocumentException.ContentMissing ex = - new SourceDocumentException.ContentMissing("src-123"); - - assertThat(ex.getMessage()).contains("src-123"); - } } From 7a9f33f6d887842abc3f8fe74ee52571ac470c54 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:58:30 +0200 Subject: [PATCH 46/70] fix: review fixes --- .../sap/cds/service/ExtractionService.java | 4 +++- .../cds/service/ExtractionServiceImpl.java | 23 +++++++++++-------- .../ConcurrentJobUpdateException.java | 10 ++++++++ .../exceptions/DocumentAiException.java | 12 ++++++++-- .../service/ExtractionServiceImplTest.java | 17 ++++++++++++++ .../service/exceptions/ExceptionsTest.java | 12 ++++++++-- 6 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index 7100447..4cdd97c 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -3,6 +3,7 @@ */ package com.sap.cds.service; +import com.sap.cds.service.exceptions.IllegalStatusTransitionException; import com.sap.cds.service.model.ExtractionResult; import com.sap.cds.services.Service; import java.io.InputStream; @@ -12,5 +13,6 @@ public interface ExtractionService extends Service { String NAME = "ExtractionService"; ExtractionResult triggerExtraction( - String fileName, String mimeType, InputStream content, String tenantId); + String fileName, String mimeType, InputStream content, String tenantId) + throws IllegalStatusTransitionException; } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index b673265..d9e22b0 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -11,6 +11,7 @@ import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; +import com.sap.cds.service.exceptions.ConcurrentJobUpdateException; import com.sap.cds.service.exceptions.IllegalStatusTransitionException; import com.sap.cds.service.model.DocumentInput; import com.sap.cds.service.model.ExtractionResult; @@ -42,7 +43,8 @@ public void init( @Override public ExtractionResult triggerExtraction( - String fileName, String mimeType, InputStream content, String tenantId) { + String fileName, String mimeType, InputStream content, String tenantId) + throws IllegalStatusTransitionException { logger.info( "[sap-document-ai] Direct extraction triggered for fileName={}, tenantId={}", fileName, @@ -70,6 +72,12 @@ private ExtractionResult performExtraction( // updateExtractionJob(jobId, PROCESSING, null); // or replace w/ documentAiJobId // updateExtractionJob(jobId, COMPLETED, null); // or replace w/ documentAiJobId return new ExtractionResult(jobId, Status.SUCCESS, documentAiJobId); + } catch (ConcurrentJobUpdateException e) { + // another thread already updated this job - treat as idempotent success + 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) { // example: COMPLETED -> FAILED logger.error("[sap-document-ai] Invalid state transition for jobId={}", jobId, e); throw e; @@ -134,15 +142,10 @@ private void updateExtractionJob(String jobId, ExtractionStatus status, String d .entry(extractionJob)); if (updateResult.rowCount() == 0) { - logger.error( - "[sap-document-ai] Status update skipped for jobId={} — concurrent modification detected (expected status={}, update affected 0 rows)", - jobId, - currentStatus); - throw new IllegalStatusTransitionException( - "Concurrent modification detected for jobId=" - + jobId - + ", expected status=" - + currentStatus); + String message = + "Concurrent update detected for jobId=" + jobId + ", expected status=" + currentStatus; + logger.warn("[sap-document-ai] {}", message); + throw new ConcurrentJobUpdateException(message); } logger.info( diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java new file mode 100644 index 0000000..1664744 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java @@ -0,0 +1,10 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.exceptions; + +public class ConcurrentJobUpdateException extends RuntimeException { + public ConcurrentJobUpdateException(String message) { + super(message); + } +} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java index 6e7da0c..6469a61 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java @@ -22,14 +22,22 @@ public Connectivity(String url, IOException cause) { } public static class Request extends DocumentAiException { - public final int statusCode; - public final String responseBody; + private final int statusCode; + private final String responseBody; public Request(int statusCode, String responseBody) { super("DIE request failed. Status=" + statusCode + ", body=" + responseBody); this.statusCode = statusCode; this.responseBody = responseBody; } + + public int getStatusCode() { + return statusCode; + } + + public String getResponseBody() { + return responseBody; + } } public static class Processing extends DocumentAiException { diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 44d4396..309c32b 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -112,6 +112,23 @@ void triggerExtractionMarksJobFailedOnProcessingError() { 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(), TENANT_1); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); + } + @Test void triggerExtractionThrowsOnInvalidStatusTransition() { mockInsertDatabaseCalls(); diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java index 81a0c4f..b8d317f 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java @@ -35,8 +35,8 @@ void documentAiRequestExceptionContainsStatusCodeAndBody() { String BAD_REQUEST = "Bad Request"; DocumentAiException.Request ex = new DocumentAiException.Request(400, BAD_REQUEST); - assertThat(ex.statusCode).isEqualTo(400); - assertThat(ex.responseBody).isEqualTo(BAD_REQUEST); + assertThat(ex.getStatusCode()).isEqualTo(400); + assertThat(ex.getResponseBody()).isEqualTo(BAD_REQUEST); assertThat(ex.getMessage()).contains("400").contains(BAD_REQUEST); } @@ -47,4 +47,12 @@ void illegalStatusTransitionExceptionContainsMessage() { 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); + } } From b9eba4279cbda24b92da4fe769867de0df07aef9 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:17:28 +0200 Subject: [PATCH 47/70] feat: outbox + scheduler + results refactor: refactor names of statuses to align with DIE service refactor: update cds version refactor: update cds version Delete sap-document-ai/sap-document-ai.iml feat: implement outbox + task scheduler + result extraction refactor: emit DocumentExtractionResult anonymously Revert "refactor: emit DocumentExtractionResult anonymously" This reverts commit 8802f3d6cb9d5a7fbff5a9afd15816690a34f731. refactor: make buildoptions dynamic refactor: revert variable renaming refactor: apply sonarcube changes test: add more unit tests refactor: cleanup --- bookshop/pom.xml | 2 +- .../handlers/CatalogServiceHandler.java | 8 +- .../handlers/DocumentExtractionHandler.java | 18 +- .../DocumentExtractionResultHandler.java | 27 +++ .../srv/src/main/resources/application.yaml | 7 + .../handlers/CatalogServiceHandlerTest.java | 6 +- sap-document-ai/pom.xml | 14 +- .../DocumentAiServiceConfiguration.java | 23 ++- .../handlers/DocumentSubmissionHandler.java | 16 +- .../handlers/ExtractionPollingHandler.java | 160 ++++++++++++++++ .../sap/cds/service/ExtractionService.java | 6 +- .../cds/service/ExtractionServiceImpl.java | 51 +++-- .../com/sap/cds/service/ExtractionStatus.java | 4 +- .../client/DefaultDocumentAiClient.java | 109 ++++++----- .../documentai/client/DocumentAiClient.java | 3 + .../exceptions/DocumentAiException.java | 4 +- .../sap/cds/service/model/DocumentInput.java | 3 +- .../sap/cds/service/model/ExtractionData.java | 7 + .../utils/StatusTransitionValidator.java | 4 +- .../sap-document-ai/document-ai-service.cds | 7 + .../sap-document-ai/extraction-job.cds | 7 +- .../cds/DocumentSubmissionHandlerTest.java | 13 +- .../ExtractionPollingHandlerTest.java | 179 ++++++++++++++++++ ...efaultDocumentAiProcessingServiceTest.java | 3 +- .../service/ExtractionServiceImplTest.java | 41 +++- .../client/DefaultDocumentAiClientTest.java | 124 ++++++++++-- .../service/exceptions/ExceptionsTest.java | 8 +- .../utils/StatusTransitionValidatorTest.java | 31 +-- 28 files changed, 748 insertions(+), 137 deletions(-) create mode 100644 bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java create mode 100644 sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java create mode 100644 sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java diff --git a/bookshop/pom.xml b/bookshop/pom.xml index 3a53afc..b171df3 100644 --- a/bookshop/pom.xml +++ b/bookshop/pom.xml @@ -17,7 +17,7 @@ 17 - 4.6.0 + 4.9.0 3.5.8 https://nodejs.org/dist/ diff --git a/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java b/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java index 934a4be..158e2be 100644 --- a/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java +++ b/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java @@ -4,7 +4,6 @@ import java.util.stream.Stream; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import com.sap.cds.ql.Select; @@ -27,8 +26,11 @@ @ServiceName(CatalogService_.CDS_NAME) public class CatalogServiceHandler implements EventHandler { - @Autowired - private PersistenceService db; + private final PersistenceService db; + + public CatalogServiceHandler(PersistenceService db) { + this.db = db; + } @On public ReturnType submitOrder(SubmitOrderContext context) { diff --git a/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java b/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java index fa65db0..874204f 100644 --- a/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java +++ b/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java @@ -6,14 +6,15 @@ 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.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; @@ -32,6 +33,7 @@ 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; @@ -106,6 +108,18 @@ public void onExtractDocumentData(BooksExtractDocumentDataContext context) { 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); diff --git a/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java b/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java new file mode 100644 index 0000000..fec7031 --- /dev/null +++ b/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java @@ -0,0 +1,27 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-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.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("sap.document.ai.DocumentAiService") +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/bookshop/srv/src/main/resources/application.yaml b/bookshop/srv/src/main/resources/application.yaml index bfb2a7d..646d843 100644 --- a/bookshop/srv/src/main/resources/application.yaml +++ b/bookshop/srv/src/main/resources/application.yaml @@ -23,3 +23,10 @@ cds: data-source: auto-config: enabled: false + outbox: + services: + DefaultOutboxUnordered: + maxAttempts: 10 + persistent: + scheduler: + enabled: true diff --git a/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java b/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java index 6c510cd..06c19b9 100644 --- a/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java +++ b/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java @@ -2,20 +2,22 @@ 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(); + private CatalogServiceHandler handler = new CatalogServiceHandler(Mockito.mock(PersistenceService.class)); private Books book = Books.create(); @BeforeEach - public void prepareBook() { + void prepareBook() { book.setTitle("title"); } diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index f058616..1ac1755 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -9,7 +9,7 @@ 17 - 4.6.0 + 4.9.0 UTF-8 com.sap.cds.feature.documentai.generated @@ -45,11 +45,17 @@ provided - org.apache.httpcomponents - httpmime - 4.5.14 + org.apache.httpcomponents.client5 + httpclient5 + 5.6 compile + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient5 + 5.28.0 + provided + org.junit.jupiter junit-jupiter diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java index 82e7818..55adb2b 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java @@ -4,6 +4,7 @@ package com.sap.cds.configuration; import com.sap.cds.handlers.DocumentSubmissionHandler; +import com.sap.cds.handlers.ExtractionPollingHandler; import com.sap.cds.service.DefaultDocumentAiProcessingService; import com.sap.cds.service.DocumentAiProcessingService; import com.sap.cds.service.ExtractionServiceImpl; @@ -11,6 +12,7 @@ import com.sap.cds.service.documentai.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; @@ -19,7 +21,7 @@ import com.sap.cloud.environment.servicebinding.api.ServiceBinding; import com.sap.cloud.sdk.cloudplatform.connectivity.*; import java.util.Optional; -import org.apache.http.client.HttpClient; +import org.apache.hc.client5.http.classic.HttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,9 +61,24 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { DocumentAiProcessingService documentAiProcessingService = new DefaultDocumentAiProcessingService(documentAiClient); - extractionService.init(persistenceService, documentAiProcessingService); + 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."); + } + + extractionService.init(persistenceService, documentAiProcessingService, outboxService); 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)); + } } static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { @@ -92,7 +109,7 @@ static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { ServiceBindingDestinationOptions.forService(binding) .onBehalfOf(OnBehalfOf.TECHNICAL_USER_CURRENT_TENANT) .build()); - HttpClient httpClient = HttpClientAccessor.getHttpClient(httpDestination); + HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); logger.info( "[sap-document-ai] Document AI destination created successfully, url={}", httpDestination.getUri()); diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java index 4de0053..39e6938 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java @@ -35,12 +35,16 @@ public void onDocumentExtraction(DocumentExtractionContext context) { ExtractionResult result = extractionService.triggerExtraction( - event.getFileName(), event.getMimeType(), event.getContent(), tenantId); - - switch (result.status()) { - case FAILED -> - logger.error("[sap-document-ai] Extraction failed for fileName={}", event.getFileName()); - case PENDING -> logger.warn("[sap-document-ai] Document AI unavailable, left as PENDING"); + 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/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java new file mode 100644 index 0000000..ab68cf9 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java @@ -0,0 +1,160 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.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.ql.Select; +import com.sap.cds.service.ExtractionService; +import com.sap.cds.service.ExtractionStatus; +import com.sap.cds.service.documentai.client.DocumentAiClient; +import com.sap.cds.service.model.ExtractionData; +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; + +@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 Duration POLL_DELAY = Duration.ofSeconds(10); + + 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; + + public ExtractionPollingHandler( + PersistenceService persistenceService, + ExtractionService extractionService, + DocumentAiClient documentAiClient, + OutboxService outboxService, + CdsRuntime runtime) { + this.persistenceService = persistenceService; + this.extractionService = extractionService; + this.documentAiClient = documentAiClient; + this.outboxService = outboxService; + this.runtime = runtime; + } + + @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.info("[sap-document-ai] Polling {} active extraction job(s)", activeJobs.size()); + + if (activeJobs.isEmpty()) { + logger.info("[sap-document-ai] No active jobs, polling stopped"); + context.setCompleted(); + return; + } + + for (ExtractionJob job : activeJobs) { + processJob(job); + } + + outboxService.submit( + POLL_EVENT, + OutboxMessage.create(), + Schedule.create().taskName(POLL_TASK_NAME).after(POLL_DELAY)); + + 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 result for jobId={}, dieJobId={} is done!!", + 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/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index 4cdd97c..b2dfaaa 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -13,6 +13,10 @@ public interface ExtractionService extends Service { String NAME = "ExtractionService"; ExtractionResult triggerExtraction( - String fileName, String mimeType, InputStream content, String tenantId) + String fileName, String mimeType, InputStream content, String options, String tenantId) + throws IllegalStatusTransitionException; + + void updateExtractionResult( + String jobId, ExtractionStatus status, String dieJobId, String extractionResult) throws IllegalStatusTransitionException; } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index d9e22b0..5da9323 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -3,6 +3,7 @@ */ package com.sap.cds.service; +import static com.sap.cds.handlers.ExtractionPollingHandler.*; import static com.sap.cds.service.ExtractionStatus.*; import com.sap.cds.Result; @@ -18,6 +19,9 @@ import com.sap.cds.service.model.ExtractionResult.Status; import com.sap.cds.service.utils.StatusTransitionValidator; 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 org.slf4j.Logger; @@ -29,6 +33,7 @@ public class ExtractionServiceImpl extends ServiceDelegator implements Extractio private PersistenceService persistenceService; private DocumentAiProcessingService documentAiProcessingService; + private OutboxService outboxService; public ExtractionServiceImpl() { super(NAME); @@ -36,14 +41,16 @@ public ExtractionServiceImpl() { public void init( PersistenceService persistenceService, - DocumentAiProcessingService documentAiProcessingService) { + DocumentAiProcessingService documentAiProcessingService, + OutboxService outboxService) { this.persistenceService = persistenceService; this.documentAiProcessingService = documentAiProcessingService; + this.outboxService = outboxService; } @Override public ExtractionResult triggerExtraction( - String fileName, String mimeType, InputStream content, String tenantId) + String fileName, String mimeType, InputStream content, String options, String tenantId) throws IllegalStatusTransitionException { logger.info( "[sap-document-ai] Direct extraction triggered for fileName={}, tenantId={}", @@ -59,29 +66,33 @@ public ExtractionResult triggerExtraction( return new ExtractionResult(jobId, Status.PENDING, null); } - DocumentInput documentInput = new DocumentInput(fileName, mimeType, content); + 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); - // TODO: transition to PROCESSING and COMPLETED via async polling callback, not here - // updateExtractionJob(jobId, PROCESSING, null); // or replace w/ documentAiJobId - // updateExtractionJob(jobId, COMPLETED, null); // or replace w/ documentAiJobId + updateExtractionJob(jobId, SUBMITTED, documentAiJobId, null); + schedulePolling(); return new ExtractionResult(jobId, Status.SUCCESS, documentAiJobId); } catch (ConcurrentJobUpdateException e) { - // another thread already updated this job - treat as idempotent success 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) { // example: COMPLETED -> FAILED + } catch (IllegalStatusTransitionException e) { logger.error("[sap-document-ai] Invalid state transition for jobId={}", jobId, e); throw e; - } catch (Exception e) { // example : PROCESSING -> FAILED + } catch (Exception e) { logger.error( "[sap-document-ai] Processing failed for fileName={}, tenantId={}", fileName, @@ -92,9 +103,21 @@ private ExtractionResult performExtraction( } } + 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(POLL_DELAY)); + logger.info("[sap-document-ai] Poll schedule submitted"); + } + private void markJobAsFailed(String jobId) { try { - updateExtractionJob(jobId, FAILED, null); + updateExtractionJob(jobId, FAILED, null, null); } catch (Exception e) { logger.error("[sap-document-ai] Failed to update status to FAILED for jobId={}", jobId, e); } @@ -111,7 +134,8 @@ private String createExtractionJob(String tenantId) { return jobId; } - private void updateExtractionJob(String jobId, ExtractionStatus status, String documentAiJobId) { + 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()); @@ -133,6 +157,9 @@ private void updateExtractionJob(String jobId, ExtractionStatus status, String d if (documentAiJobId != null) { extractionJob.setDocumentAiJobId(documentAiJobId); } + if (extractionResult != null) { + extractionJob.setExtractionResult(extractionResult); + } Result updateResult = persistenceService.run( diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java index d2c26fb..433c38e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java @@ -6,8 +6,8 @@ public enum ExtractionStatus { PENDING, SUBMITTED, - PROCESSING, - COMPLETED, + RUNNING, + DONE, FAILED; public static ExtractionStatus fromString(String value) { diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java index f18977e..1b57280 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java @@ -8,25 +8,27 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.service.exceptions.DocumentAiException; import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.service.model.ExtractionData; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; import java.io.IOException; import java.net.URI; -import java.util.List; -import java.util.Map; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.entity.ContentType; -import org.apache.http.entity.mime.MultipartEntityBuilder; -import org.apache.http.util.EntityUtils; +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; public class DefaultDocumentAiClient implements DocumentAiClient { private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiClient.class); - private static final String DOCUMENT_AI_API_PATH = "document-information-extraction/v1"; 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; @@ -37,16 +39,26 @@ public DefaultDocumentAiClient(HttpDestination destination, HttpClient httpClien @Override public String submitDocument(DocumentInput documentInput) { - URI submitUri = buildSubmitUri(); + URI submitUri = buildUri(DOCUMENT_AI_API_PATH + DOCUMENT_JOBS); HttpPost request = buildSubmitRequest(documentInput, submitUri); String body = executeRequest(request, submitUri); return extractJobId(body); } - private URI buildSubmitUri() { + @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 path = base.endsWith("/") ? base : base + "/"; - return URI.create(path).resolve(DOCUMENT_AI_API_PATH + "/document/jobs"); + String prefix = base.endsWith("/") ? base : base + "/"; + return URI.create(prefix).resolve(path); } private HttpPost buildSubmitRequest(DocumentInput documentInput, URI submitUri) { @@ -56,39 +68,42 @@ private HttpPost buildSubmitRequest(DocumentInput documentInput, URI submitUri) documentInput.fileName(), documentInput.mimeType()); - String optionsJson = buildOptionsJson(); - 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", optionsJson, ContentType.APPLICATION_JSON) + .addTextBody("options", options, ContentType.APPLICATION_JSON) .build()); - logger.info("[sap-document-ai] POST {} | Headers: {}", submitUri, request.getAllHeaders()); + logger.info("[sap-document-ai] POST {} | Headers: {}", submitUri, request.getHeaders()); return request; } - private String executeRequest(HttpPost request, URI submitUri) { - - try (CloseableHttpResponse response = (CloseableHttpResponse) httpClient.execute(request)) { - - String body = EntityUtils.toString(response.getEntity()); - - int statusCode = response.getStatusLine().getStatusCode(); - - if (statusCode < 200 || statusCode >= 300) { - throw new DocumentAiException.Request(statusCode, body); - } - - return body; - + 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(submitUri.toString(), e); + throw new DocumentAiException.Connectivity(uri.toString(), e); } } @@ -97,7 +112,7 @@ private String extractJobId(String body) { JsonNode json = objectMapper.readTree(body); if (!json.has("id")) { - throw new RuntimeException("Unexpected DIE response. body=" + body); + throw new DocumentAiException.Processing("Unexpected DIE response. body=" + body, null); } String jobId = json.get("id").asText(); @@ -105,21 +120,23 @@ private String extractJobId(String body) { return jobId; } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to parse DIE response", e); + throw new DocumentAiException.Processing("Failed to parse DIE response", e); } } - private String buildOptionsJson() { - // TODO: Currently options are hard-coded. Make these dynamic - Map options = - Map.of( - "clientId", "default", - "documentType", "invoice", - "receivedDate", "2020-02-17", - "schemaId", "cf8cc8a9-1eee-42d9-9a3e-507a61baac23", - "templateId", "detect", - "candidateTemplateIds", List.of(), - "enrichment", Map.of()); - return objectMapper.valueToTree(options).toString(); + 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.info("[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/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java index 2f436df..6b7325e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java @@ -4,7 +4,10 @@ package com.sap.cds.service.documentai.client; import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.service.model.ExtractionData; public interface DocumentAiClient { String submitDocument(DocumentInput documentInput); + + ExtractionData getJobResult(String dieJobId); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java index 6469a61..b6d16f9 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java @@ -3,8 +3,6 @@ */ package com.sap.cds.service.exceptions; -import java.io.IOException; - public class DocumentAiException extends RuntimeException { protected DocumentAiException(String message, Throwable cause) { @@ -16,7 +14,7 @@ protected DocumentAiException(String message) { } public static class Connectivity extends DocumentAiException { - public Connectivity(String url, IOException cause) { + public Connectivity(String url, Exception cause) { super("Failed to connect to DIE at " + url, cause); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java index 8f71a69..db962a2 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java @@ -5,4 +5,5 @@ import java.io.InputStream; -public record DocumentInput(String fileName, String mimeType, InputStream content) {} +public record DocumentInput( + String fileName, String mimeType, InputStream content, String options) {} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java new file mode 100644 index 0000000..bf84554 --- /dev/null +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java @@ -0,0 +1,7 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.service.model; + +// DocumentExtractionResult +public record ExtractionData(String dieJobId, String dieStatus, String rawResult) {} diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java b/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java index 5776e6a..2e4a1b0 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java @@ -16,8 +16,8 @@ public static boolean isValid(ExtractionStatus current, ExtractionStatus next) { return switch (current) { case PENDING -> SUBMITTED.equals(next) || FAILED.equals(next); - case SUBMITTED -> PROCESSING.equals(next) || FAILED.equals(next); - case PROCESSING -> COMPLETED.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/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds index 3fa5f33..ee2e026 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds @@ -5,5 +5,12 @@ service DocumentAiService { fileName : String; mimeType : String @Core.IsMediaType; content : LargeBinary @Core.MediaType: mimeType; + options : LargeString; + } + + event DocumentExtractionResult { + jobId : String; + documentAiJobId : String; + extractionResult : LargeString; } } diff --git a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds index f9fdd00..bfc75af 100644 --- a/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds +++ b/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds @@ -6,7 +6,8 @@ using { } from '@sap/cds/common'; entity ExtractionJob : cuid, managed { - status : String; - tenantId : String; - documentAiJobId : String; + status : String; + tenantId : String; + documentAiJobId : String; + extractionResult : LargeString; } diff --git a/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java index e351127..85d8e1e 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java @@ -52,14 +52,15 @@ 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())) + 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), eq(TENANT_ID)); + .triggerExtraction( + eq(FILE_NAME), eq(MIME_TYPE), any(InputStream.class), any(), eq(TENANT_ID)); } @Test @@ -67,12 +68,12 @@ 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())) + 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()); + verify(extractionService).triggerExtraction(any(), any(), any(), any(), any()); } @Test @@ -80,11 +81,11 @@ 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())) + 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()); + verify(extractionService).triggerExtraction(any(), any(), any(), any(), any()); } } diff --git a/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java new file mode 100644 index 0000000..98c6b27 --- /dev/null +++ b/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java @@ -0,0 +1,179 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.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.ql.cqn.CqnSelect; +import com.sap.cds.service.ExtractionService; +import com.sap.cds.service.ExtractionStatus; +import com.sap.cds.service.documentai.client.DocumentAiClient; +import com.sap.cds.service.model.ExtractionData; +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.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); + } + + 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/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java index 4836018..8834f0c 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java @@ -38,7 +38,8 @@ void setUp() { new DocumentInput( TEST_PDF, CONTENT_TYPE, - new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8))); + new ByteArrayInputStream(TEST_CONTENT.getBytes(StandardCharsets.UTF_8)), + null); } // ----- isAvailable() ------- diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index 309c32b..b7f0e7f 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -15,6 +15,8 @@ import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.service.exceptions.IllegalStatusTransitionException; import com.sap.cds.service.model.ExtractionResult; +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; @@ -37,6 +39,7 @@ class ExtractionServiceImplTest { @Mock PersistenceService persistenceService; @Mock DocumentAiProcessingService documentAiProcessingService; + @Mock OutboxService outboxService; @Mock Result insertResult; ExtractionServiceImpl extractionService; @@ -45,7 +48,7 @@ class ExtractionServiceImplTest { void setUp() { when(documentAiProcessingService.isAvailable()).thenReturn(true); extractionService = new ExtractionServiceImpl(); - extractionService.init(persistenceService, documentAiProcessingService); + extractionService.init(persistenceService, documentAiProcessingService, outboxService); } @Test @@ -54,7 +57,8 @@ void triggerExtractionCreatesJobAsPendingWhenServiceUnavailable() { when(documentAiProcessingService.isAvailable()).thenReturn(false); ExtractionResult result = - extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); assertThat(result.internalJobId()).isNotNull(); @@ -69,7 +73,7 @@ void triggerExtractionCreatesJobWithCorrectFields() { 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(), TENANT_1); + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); var insertCaptor = org.mockito.ArgumentCaptor.forClass(CqnInsert.class); verify(persistenceService, atLeastOnce()).run(insertCaptor.capture()); @@ -88,11 +92,29 @@ void triggerExtractionSubmitsDocumentAndUpdatesStatusToSubmitted() { when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); ExtractionResult result = - extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + 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); + 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 @@ -106,7 +128,8 @@ void triggerExtractionMarksJobFailedOnProcessingError() { .processDocument(any(), any()); ExtractionResult result = - extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + 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)); @@ -124,7 +147,8 @@ void triggerExtractionReturnSuccessOnConcurrentUpdate() { when(persistenceService.run(any(CqnUpdate.class))).thenReturn(zeroRowResult); ExtractionResult result = - extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1); + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); assertThat(result.status()).isEqualTo(ExtractionResult.Status.SUCCESS); } @@ -142,7 +166,8 @@ void triggerExtractionThrowsOnInvalidStatusTransition() { assertThrows( IllegalStatusTransitionException.class, () -> - extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), TENANT_1)); + extractionService.triggerExtraction( + TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1)); verify(persistenceService, never()).run(any(CqnUpdate.class)); } @@ -154,7 +179,7 @@ void updateStatusWithSameStateSkipsUpdate() { 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(), TENANT_1); + extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); verify(persistenceService, never()).run(any(CqnUpdate.class)); } diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java index 8090627..098129f 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java @@ -10,18 +10,22 @@ import com.sap.cds.service.exceptions.DocumentAiException; import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.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.http.HttpEntity; -import org.apache.http.StatusLine; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpUriRequest; +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; @@ -33,8 +37,7 @@ class DefaultDocumentAiClientTest { @Mock HttpDestination destination; @Mock HttpClient httpClient; - @Mock CloseableHttpResponse response; - @Mock StatusLine statusLine; + @Mock ClassicHttpResponse response; @Mock HttpEntity entity; DefaultDocumentAiClient client; @@ -45,7 +48,10 @@ void setUp() { client = new DefaultDocumentAiClient(destination, httpClient); documentInput = new DocumentInput( - "invoice.pdf", "application/pdf", new ByteArrayInputStream("pdf-bytes".getBytes())); + "invoice.pdf", + "application/pdf", + new ByteArrayInputStream("pdf-bytes".getBytes()), + null); when(destination.getUri()).thenReturn(URI.create(BASE_URL)); } @@ -69,7 +75,8 @@ void submitDocumentThrowsRequestExceptionOnNon2xxResponse() throws IOException { @Test void submitDocumentThrowsConnectivityExceptionOnIoFailure() throws IOException { - when(httpClient.execute(any(HttpUriRequest.class))).thenThrow(new IOException("timeout")); + when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) + .thenThrow(new IOException("timeout")); assertThatThrownBy(() -> client.submitDocument(documentInput)) .isInstanceOf(DocumentAiException.Connectivity.class) @@ -81,7 +88,7 @@ void submitDocumentThrowsWhenResponseHasNoIdField() throws IOException { mockHttpResponse(200, "{\"status\":\"ok\"}"); assertThatThrownBy(() -> client.submitDocument(documentInput)) - .isInstanceOf(RuntimeException.class) + .isInstanceOf(DocumentAiException.Processing.class) .hasMessageContaining("Unexpected DIE response"); } @@ -90,16 +97,105 @@ void submitDocumentThrowsWhenResponseIsNotValidJson() throws IOException { mockHttpResponse(200, "not-json{{{{"); assertThatThrownBy(() -> client.submitDocument(documentInput)) - .isInstanceOf(RuntimeException.class) + .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("{}"); + assertThat(requestBody).contains("options"); + } + + @SuppressWarnings("unchecked") private void mockHttpResponse(int statusCode, String body) throws IOException { - when(httpClient.execute(any(HttpUriRequest.class))).thenReturn(response); - when(response.getStatusLine()).thenReturn(statusLine); + when(response.getCode()).thenReturn(statusCode); when(response.getEntity()).thenReturn(entity); - when(statusLine.getStatusCode()).thenReturn(statusCode); 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/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java index b8d317f..06ca11e 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java @@ -32,12 +32,12 @@ void documentAiProcessingExceptionContainsMessageAndCause() { @Test void documentAiRequestExceptionContainsStatusCodeAndBody() { - String BAD_REQUEST = "Bad Request"; - DocumentAiException.Request ex = new DocumentAiException.Request(400, BAD_REQUEST); + String badRequest = "Bad Request"; + DocumentAiException.Request ex = new DocumentAiException.Request(400, badRequest); assertThat(ex.getStatusCode()).isEqualTo(400); - assertThat(ex.getResponseBody()).isEqualTo(BAD_REQUEST); - assertThat(ex.getMessage()).contains("400").contains(BAD_REQUEST); + assertThat(ex.getResponseBody()).isEqualTo(badRequest); + assertThat(ex.getMessage()).contains("400").contains(badRequest); } @Test diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java index b381c9e..a81c2e2 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java @@ -21,8 +21,8 @@ void pendingToFailedIsValid() { } @Test - void submittedToProcessingIsValid() { - Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, PROCESSING)).isTrue(); + void submittedToRunningIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, RUNNING)).isTrue(); } @Test @@ -31,32 +31,37 @@ void submittedToFailedIsValid() { } @Test - void processingToCompletedIsValid() { - Assertions.assertThat(StatusTransitionValidator.isValid(PROCESSING, COMPLETED)).isTrue(); + void submittedToDoneIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(SUBMITTED, DONE)).isTrue(); } @Test - void processingToFailedIsValid() { - Assertions.assertThat(StatusTransitionValidator.isValid(PROCESSING, FAILED)).isTrue(); + void runningToDoneIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, DONE)).isTrue(); } @Test - void pendingToCompletedIsInvalid() { - Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, COMPLETED)).isFalse(); + void runningToFailedIsValid() { + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, FAILED)).isTrue(); } @Test - void processingToPendingIsInvalid() { - Assertions.assertThat(StatusTransitionValidator.isValid(PROCESSING, PENDING)).isFalse(); + void pendingToDoneIsInvalid() { + Assertions.assertThat(StatusTransitionValidator.isValid(PENDING, DONE)).isFalse(); } @Test - void completedToProcessingIsInvalid() { - Assertions.assertThat(StatusTransitionValidator.isValid(COMPLETED, PROCESSING)).isFalse(); + 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(PROCESSING, PROCESSING)).isTrue(); + Assertions.assertThat(StatusTransitionValidator.isValid(RUNNING, RUNNING)).isTrue(); } } From eef72dc81d8218618f3ff3b7a715244f42ea35db Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:21:34 +0200 Subject: [PATCH 48/70] fix: review fix --- .../sap/cds/handlers/ExtractionPollingHandler.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java index ab68cf9..9223e8a 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java @@ -82,10 +82,14 @@ public void pollExtractionJobs(OutboxMessageEventContext context) { processJob(job); } - outboxService.submit( - POLL_EVENT, - OutboxMessage.create(), - Schedule.create().taskName(POLL_TASK_NAME).after(POLL_DELAY)); + if (outboxService != null) { + outboxService.submit( + POLL_EVENT, + OutboxMessage.create(), + Schedule.create().taskName(POLL_TASK_NAME).after(POLL_DELAY)); + } else { + logger.warn("[sap-document-ai] Outbox not available, next poll cycle will not be scheduled"); + } context.setCompleted(); } From feb5c3575e8be42e567af289228d30295a5e1fc0 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:18:36 +0200 Subject: [PATCH 49/70] fix: fix: use wildcard ServiceName for DocumentExtractionResult handler to decouple consumer from plugin service name --- .../bookshop/handlers/DocumentExtractionResultHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java b/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java index fec7031..8cb2947 100644 --- a/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java +++ b/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java @@ -5,6 +5,7 @@ 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; @@ -13,7 +14,7 @@ import org.springframework.stereotype.Component; @Component -@ServiceName("sap.document.ai.DocumentAiService") +@ServiceName(value = "*", type = ApplicationService.class) public class DocumentExtractionResultHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(DocumentExtractionResultHandler.class); From bb6404afccfad611c99977b0693a8566fb04b3f5 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:02:08 +0200 Subject: [PATCH 50/70] chore: update the cds version to LTS --- bookshop/pom.xml | 2 +- sap-document-ai/pom.xml | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/bookshop/pom.xml b/bookshop/pom.xml index b171df3..d8b2f4a 100644 --- a/bookshop/pom.xml +++ b/bookshop/pom.xml @@ -17,7 +17,7 @@ 17 - 4.9.0 + 4.9.1 3.5.8 https://nodejs.org/dist/ diff --git a/sap-document-ai/pom.xml b/sap-document-ai/pom.xml index 1ac1755..c8d2ca4 100644 --- a/sap-document-ai/pom.xml +++ b/sap-document-ai/pom.xml @@ -9,7 +9,7 @@ 17 - 4.9.0 + 4.9.1 UTF-8 com.sap.cds.feature.documentai.generated @@ -44,12 +44,6 @@ 6.2.6 provided - - org.apache.httpcomponents.client5 - httpclient5 - 5.6 - compile - com.sap.cloud.sdk.cloudplatform connectivity-apache-httpclient5 From 6e51cb540843b21179888b474002a3f851a0b1f1 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:00:05 +0200 Subject: [PATCH 51/70] docs: add javadcos to classes and methods --- .../DocumentAiServiceConfiguration.java | 40 ++++++++++++++++++ .../handlers/DocumentSubmissionHandler.java | 18 ++++++++ .../handlers/ExtractionPollingHandler.java | 32 ++++++++++++--- .../DefaultDocumentAiProcessingService.java | 21 ++++------ .../service/DocumentAiProcessingService.java | 21 ++++++++++ .../sap/cds/service/ExtractionService.java | 35 ++++++++++++++++ .../cds/service/ExtractionServiceImpl.java | 33 +++++++++++++-- .../com/sap/cds/service/ExtractionStatus.java | 24 +++++++++++ .../client/DefaultDocumentAiClient.java | 24 ++++++++++- .../documentai/client/DocumentAiClient.java | 23 +++++++++++ .../ConcurrentJobUpdateException.java | 12 ++++++ .../exceptions/DocumentAiException.java | 41 +++++++++++++++++++ .../IllegalStatusTransitionException.java | 8 ++++ .../sap/cds/service/model/DocumentInput.java | 8 ++++ .../sap/cds/service/model/ExtractionData.java | 10 ++++- .../cds/service/model/ExtractionResult.java | 14 +++++++ .../utils/StatusTransitionValidator.java | 21 ++++++++++ 17 files changed, 361 insertions(+), 24 deletions(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java index 55adb2b..9ff8b66 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java @@ -25,6 +25,24 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * CDS plugin configuration that wires up all Document AI services and event handlers at runtime. + * + *

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

Responsibilities: + * + *

    + *
  • Registers {@link ExtractionServiceImpl} as a CDS service. + *
  • Resolves the DIE service binding from the environment and builds an authenticated {@link + * DefaultDocumentAiClient} via the SAP Cloud SDK destination API. + *
  • Wires all dependencies into {@link ExtractionServiceImpl} and registers the {@link + * DocumentSubmissionHandler} and (when a binding is present) the {@link + * ExtractionPollingHandler}. + *
+ */ public class DocumentAiServiceConfiguration implements CdsRuntimeConfiguration { private static final Logger logger = @@ -41,12 +59,24 @@ public class DocumentAiServiceConfiguration implements CdsRuntimeConfiguration { 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(); @@ -81,6 +111,16 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } } + /** + * 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 DefaultDocumentAiClient}, or {@code null} if unavailable + */ static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { Optional optionalBinding = environment diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java index 39e6938..0ece9a6 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java @@ -14,6 +14,15 @@ 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 { @@ -25,6 +34,15 @@ 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(); diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java index 9223e8a..d96feba 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java @@ -28,6 +28,24 @@ 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 #POLL_DELAY}. + *
+ * + *

This self-rescheduling pattern means polling stops automatically once all jobs reach a + * terminal status ({@code DONE} or {@code FAILED}), avoiding unnecessary cycles. + */ @ServiceName(value = ExtractionPollingHandler.OUTBOX_NAME, type = OutboxService.class) public class ExtractionPollingHandler implements EventHandler { @@ -57,6 +75,12 @@ public ExtractionPollingHandler( this.runtime = runtime; } + /** + * 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 = @@ -70,10 +94,10 @@ public void pollExtractionJobs(OutboxMessageEventContext context) { .or(j.status().eq(ExtractionStatus.RUNNING.name())))) .listOf(ExtractionJob.class); - logger.info("[sap-document-ai] Polling {} active extraction job(s)", activeJobs.size()); + logger.debug("[sap-document-ai] Polling {} active extraction job(s)", activeJobs.size()); if (activeJobs.isEmpty()) { - logger.info("[sap-document-ai] No active jobs, polling stopped"); + logger.debug("[sap-document-ai] No active jobs, polling stopped"); context.setCompleted(); return; } @@ -121,9 +145,7 @@ private void processJob(ExtractionJob job) { if (newStatus == ExtractionStatus.DONE) { logger.info( - "[sap-document-ai] Extraction result for jobId={}, dieJobId={} is done!!", - jobId, - dieJobId); + "[sap-document-ai] Extraction complete for jobId={}, dieJobId={}", jobId, dieJobId); emitExtractionCompleted(jobId, extractionResult); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java index 024a5ab..5fa2f92 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java @@ -6,13 +6,17 @@ import com.sap.cds.service.documentai.client.DocumentAiClient; import com.sap.cds.service.exceptions.DocumentAiException; import com.sap.cds.service.model.DocumentInput; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +/** + * 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 { - private static final Logger logger = - LoggerFactory.getLogger(DefaultDocumentAiProcessingService.class); public static final String SAP_DOCUMENT_AI_SERVICE_LABEL = "sap-document-information-extraction"; private final DocumentAiClient documentAiClient; @@ -23,17 +27,8 @@ public DefaultDocumentAiProcessingService(DocumentAiClient documentAiClient) { @Override public String processDocument(String jobId, DocumentInput documentInput) { - logger.info( - "[sap-document-ai] Processing document for jobId={}, fileName={}", - jobId, - documentInput.fileName()); - try { String documentAiJobId = documentAiClient.submitDocument(documentInput); - logger.info( - "[sap-document-ai] Document submitted successfully for jobId={}, DIE jobId={}", - jobId, - documentAiJobId); return documentAiJobId; } catch (Exception e) { throw new DocumentAiException.Processing("Failed to process document for jobId=" + jobId, e); diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java index 487c9b0..973e379 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java @@ -5,9 +5,30 @@ import com.sap.cds.service.model.DocumentInput; +/** + * Abstraction over the Document AI (DIE) submission layer. + * + *

Decouples {@link com.sap.cds.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.service.exceptions.DocumentAiException if submission or response parsing + * fails + */ String processDocument(String jobId, DocumentInput documentInput); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java index b2dfaaa..1863c74 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java @@ -8,14 +8,49 @@ 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/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 5da9323..64cd4e2 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -27,6 +27,23 @@ 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.service.exceptions.ConcurrentJobUpdateException}. + */ public class ExtractionServiceImpl extends ServiceDelegator implements ExtractionService { private static final Logger logger = LoggerFactory.getLogger(ExtractionServiceImpl.class); @@ -39,6 +56,17 @@ public ExtractionServiceImpl() { super(NAME); } + /** + * Injects runtime dependencies after Spring/CDS wiring is complete. + * + *

Called from {@link com.sap.cds.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 + */ public void init( PersistenceService persistenceService, DocumentAiProcessingService documentAiProcessingService, @@ -56,10 +84,9 @@ public ExtractionResult triggerExtraction( "[sap-document-ai] Direct extraction triggered for fileName={}, tenantId={}", fileName, tenantId); - // create pending job + String jobId = createExtractionJob(tenantId); - // check for availability of the service. if (!documentAiProcessingService.isAvailable()) { logger.warn( "[sap-document-ai] Document AI unavailable, job {} left as PENDING for retry", jobId); @@ -112,7 +139,7 @@ private void schedulePolling() { POLL_EVENT, OutboxMessage.create(), Schedule.create().taskName(POLL_TASK_NAME).after(POLL_DELAY)); - logger.info("[sap-document-ai] Poll schedule submitted"); + logger.debug("[sap-document-ai] Poll schedule submitted"); } private void markJobAsFailed(String jobId) { diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java index 433c38e..db9a4ce 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java @@ -3,13 +3,37 @@ */ package com.sap.cds.service; +/** + * Lifecycle statuses for a document extraction job. + * + *

The allowed transitions are enforced by {@link + * com.sap.cds.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); diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java index 1b57280..dd9c17d 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java @@ -22,6 +22,22 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Default {@link DocumentAiClient} implementation that communicates with the DIE REST API over HTTP + * using the SAP Cloud SDK destination and Apache HttpClient 5. + * + *

Two operations are provided: + * + *

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

All HTTP failures and unexpected response shapes are wrapped in the appropriate {@link + * com.sap.cds.service.exceptions.DocumentAiException} subclass. + */ public class DefaultDocumentAiClient implements DocumentAiClient { private static final Logger logger = LoggerFactory.getLogger(DefaultDocumentAiClient.class); @@ -32,6 +48,11 @@ public class DefaultDocumentAiClient implements DocumentAiClient { 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; @@ -86,7 +107,6 @@ private HttpPost buildSubmitRequest(DocumentInput documentInput, URI submitUri) .addTextBody("options", options, ContentType.APPLICATION_JSON) .build()); - logger.info("[sap-document-ai] POST {} | Headers: {}", submitUri, request.getHeaders()); return request; } @@ -133,7 +153,7 @@ private ExtractionData parseJobResult(String dieJobId, String body) { "DIE job response missing 'status' field for dieJobId=" + dieJobId + ". body=" + body, null); } - logger.info("[sap-document-ai] DIE job dieJobId={} status={}", dieJobId, status); + 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/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java index 6b7325e..aa7a53e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java @@ -6,8 +6,31 @@ import com.sap.cds.service.model.DocumentInput; import com.sap.cds.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.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.service.exceptions.DocumentAiException if the HTTP call fails or the + * response cannot be parsed + */ ExtractionData getJobResult(String dieJobId); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java index 1664744..73ec1a4 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java @@ -3,7 +3,19 @@ */ package com.sap.cds.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/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java index b6d16f9..e351c27 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java @@ -3,42 +3,83 @@ */ package com.sap.cds.service.exceptions; +/** + * Base exception for all errors originating from interaction with the Document AI (DIE) service. + * + *

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

    + *
  • {@link Connectivity} — network-level failures (timeouts, DNS, etc.) + *
  • {@link Request} — non-2xx HTTP responses from DIE + *
  • {@link Processing} — unexpected or malformed response payloads + *
+ */ public class DocumentAiException extends RuntimeException { + /** + * @param message human-readable description of the failure + * @param cause the underlying exception, or {@code null} + */ protected DocumentAiException(String message, Throwable cause) { super(message, cause); } + /** + * @param message human-readable description of the failure + */ protected DocumentAiException(String message) { super(message); } + /** Raised when the HTTP connection to the DIE service cannot be established. */ public static class Connectivity extends DocumentAiException { + + /** + * @param url the URL that was being contacted when the error occurred + * @param cause the underlying I/O exception + */ public Connectivity(String url, Exception cause) { super("Failed to connect to DIE at " + url, cause); } } + /** Raised when DIE returns a non-2xx HTTP response. */ public static class Request extends DocumentAiException { private final int statusCode; private final String responseBody; + /** + * @param statusCode the HTTP status code returned by DIE + * @param responseBody the raw response body, included for diagnostics + */ public Request(int statusCode, String responseBody) { super("DIE request failed. Status=" + statusCode + ", body=" + responseBody); this.statusCode = statusCode; this.responseBody = responseBody; } + /** + * @return the HTTP status code returned by DIE + */ public int getStatusCode() { return statusCode; } + /** + * @return the raw response body returned by DIE + */ public String getResponseBody() { return responseBody; } } + /** Raised when a DIE response cannot be parsed or is missing required fields. */ public static class Processing extends DocumentAiException { + + /** + * @param message description of the parsing failure + * @param cause the underlying parse exception, or {@code null} + */ public Processing(String message, Throwable cause) { super(message, cause); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java index 192bbb5..834ebc2 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java @@ -3,7 +3,15 @@ */ package com.sap.cds.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.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/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java index db962a2..7c39f4e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java @@ -5,5 +5,13 @@ 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/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java index bf84554..a44d1e2 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java @@ -3,5 +3,13 @@ */ package com.sap.cds.service.model; -// DocumentExtractionResult +/** + * 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/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java index 94fb3c0..db569d7 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java @@ -3,11 +3,25 @@ */ package com.sap.cds.service.model; +/** + * Immutable value object returned by {@link + * com.sap.cds.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/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java b/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java index 2e4a1b0..c9cfc1e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java @@ -7,10 +7,31 @@ import com.sap.cds.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 From 9dd3cbd7f9c9cdbc3ffadd6fdef0a408ef7d866f Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:02:39 +0200 Subject: [PATCH 52/70] fix: review fix --- .../sap/cds/configuration/DocumentAiServiceConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java index 9ff8b66..86f73f0 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java @@ -119,7 +119,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { * 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 DefaultDocumentAiClient}, or {@code null} if unavailable + * @return a configured {@link DocumentAiClient}, or {@code null} if unavailable */ static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { Optional optionalBinding = From 5b9bc67950c0b088b420dbfe272105d2736c1178 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:53:18 +0200 Subject: [PATCH 53/70] docs: write documentation about the plugin --- README.md | 383 ++++++++++++++++++++++++++++++++++++++++++- docs/architecture.md | 231 ++++++++++++++++++++++++++ 2 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 docs/architecture.md diff --git a/README.md b/README.md index 48f366f..cc4378e 100644 --- a/README.md +++ b/README.md @@ -1 +1,382 @@ -# cds-feature-sap-document-ai \ No newline at end of file +# 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) + +--- + +## 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.handlers.DocumentSubmissionHandler: DEBUG + com.sap.cds.handlers.ExtractionPollingHandler: DEBUG + com.sap.cds.service.ExtractionServiceImpl: DEBUG + com.sap.cds.service.documentai.client.DefaultDocumentAiClient: 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%. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c2f4a25 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,231 @@ +# 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) + - [Integration Tests](#integration-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 | +| `integration-tests` | Integration test module | +| `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.configuration` | Bootstraps all plugin components and registers them with the CDS runtime at startup | +| `com.sap.cds.handlers` | CDS event handlers for document submission and outbox-driven polling | +| `com.sap.cds.service` | Core extraction service, processing service, status enum, and transition validator | +| `com.sap.cds.service.documentai.client` | HTTP client abstraction for the DIE REST API | +| `com.sap.cds.service.model` | Immutable value objects used as internal data transfer types | +| `com.sap.cds.service.exceptions` | Typed exceptions for error classification | +| `com.sap.cds.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). + +### Integration Tests + +Integration tests are located in the `integration-tests` module. They run a full Spring Boot application against an in-memory H2 database and verify end-to-end behaviour including CDS model deployment, event dispatch, and outbox scheduling. + +--- + +## 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`) | From 2955ec379885a74006a730b63f6bf4fefa7a0262 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:23:23 +0200 Subject: [PATCH 54/70] feat: make polling intervals dynamic --- .../srv/src/main/resources/application.yaml | 3 +++ .../DocumentAiServiceConfiguration.java | 20 +++++++++++++++++-- .../handlers/ExtractionPollingHandler.java | 20 ++++++++++++++++--- .../cds/service/ExtractionServiceImpl.java | 10 ++++++++-- .../DocumentAiServiceConfigurationTest.java | 3 +++ .../ExtractionPollingHandlerTest.java | 8 +++++++- .../service/ExtractionServiceImplTest.java | 7 +++++-- 7 files changed, 61 insertions(+), 10 deletions(-) diff --git a/bookshop/srv/src/main/resources/application.yaml b/bookshop/srv/src/main/resources/application.yaml index 646d843..5334145 100644 --- a/bookshop/srv/src/main/resources/application.yaml +++ b/bookshop/srv/src/main/resources/application.yaml @@ -30,3 +30,6 @@ cds: persistent: scheduler: enabled: true + document-ai: + polling: + interval-seconds: 5 diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java index 86f73f0..11e3e8b 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java @@ -20,6 +20,7 @@ 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; @@ -99,7 +100,17 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { "[sap-document-ai] Persistent outbox not available — polling scheduler disabled. Ensure cds.outbox.persistent is configured."); } - extractionService.init(persistenceService, documentAiProcessingService, outboxService); + 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)); @@ -107,7 +118,12 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { if (documentAiClient != null) { configurer.eventHandler( new ExtractionPollingHandler( - persistenceService, extractionService, documentAiClient, outboxService, runtime)); + persistenceService, + extractionService, + documentAiClient, + outboxService, + runtime, + pollDelay)); } } diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java b/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java index d96feba..bb973b3 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java @@ -45,6 +45,9 @@ * *

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 { @@ -52,7 +55,7 @@ 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 Duration POLL_DELAY = Duration.ofSeconds(10); + public static final int DEFAULT_POLL_INTERVAL_SECONDS = 3; private static final Logger logger = LoggerFactory.getLogger(ExtractionPollingHandler.class); @@ -61,18 +64,29 @@ public class ExtractionPollingHandler implements EventHandler { 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) { + CdsRuntime runtime, + Duration pollDelay) { this.persistenceService = persistenceService; this.extractionService = extractionService; this.documentAiClient = documentAiClient; this.outboxService = outboxService; this.runtime = runtime; + this.pollDelay = pollDelay; } /** @@ -110,7 +124,7 @@ public void pollExtractionJobs(OutboxMessageEventContext context) { outboxService.submit( POLL_EVENT, OutboxMessage.create(), - Schedule.create().taskName(POLL_TASK_NAME).after(POLL_DELAY)); + Schedule.create().taskName(POLL_TASK_NAME).after(pollDelay)); } else { logger.warn("[sap-document-ai] Outbox not available, next poll cycle will not be scheduled"); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java index 64cd4e2..a27782e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java @@ -24,6 +24,7 @@ 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; @@ -51,6 +52,7 @@ public class ExtractionServiceImpl extends ServiceDelegator implements Extractio private PersistenceService persistenceService; private DocumentAiProcessingService documentAiProcessingService; private OutboxService outboxService; + private Duration pollDelay; public ExtractionServiceImpl() { super(NAME); @@ -66,14 +68,18 @@ public ExtractionServiceImpl() { * @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) { + OutboxService outboxService, + Duration pollDelay) { this.persistenceService = persistenceService; this.documentAiProcessingService = documentAiProcessingService; this.outboxService = outboxService; + this.pollDelay = pollDelay; } @Override @@ -138,7 +144,7 @@ private void schedulePolling() { outboxService.submit( POLL_EVENT, OutboxMessage.create(), - Schedule.create().taskName(POLL_TASK_NAME).after(POLL_DELAY)); + Schedule.create().taskName(POLL_TASK_NAME).after(pollDelay)); logger.debug("[sap-document-ai] Poll schedule submitted"); } diff --git a/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java b/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java index 471cca4..6f031bd 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java @@ -61,6 +61,9 @@ void eventHandlersRegistersDocumentSubmissionHandler() { 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); diff --git a/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java index 98c6b27..7279be1 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java @@ -22,6 +22,7 @@ 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; @@ -52,7 +53,12 @@ class ExtractionPollingHandlerTest { void setUp() { handler = new ExtractionPollingHandler( - persistenceService, extractionService, documentAiClient, outboxService, runtime); + persistenceService, + extractionService, + documentAiClient, + outboxService, + runtime, + Duration.ofSeconds(ExtractionPollingHandler.DEFAULT_POLL_INTERVAL_SECONDS)); } private void mockEmit() { diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java index b7f0e7f..1b292c8 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java @@ -21,6 +21,7 @@ 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; @@ -48,7 +49,8 @@ class ExtractionServiceImplTest { void setUp() { when(documentAiProcessingService.isAvailable()).thenReturn(true); extractionService = new ExtractionServiceImpl(); - extractionService.init(persistenceService, documentAiProcessingService, outboxService); + extractionService.init( + persistenceService, documentAiProcessingService, outboxService, Duration.ofSeconds(3)); } @Test @@ -103,7 +105,8 @@ void triggerExtractionSubmitsDocumentAndUpdatesStatusToSubmitted() { @Test void triggerExtractionDoesNotThrowWhenOutboxIsNull() { - extractionService.init(persistenceService, documentAiProcessingService, null); + extractionService.init( + persistenceService, documentAiProcessingService, null, Duration.ofSeconds(3)); mockInsertDatabaseCalls(); mockAllDatabaseCalls(); Result statusResult = resultWithJobStatus(PENDING); From d9214af60496fb3a591ecc0e25ecc24751fbd2c5 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:00:00 +0200 Subject: [PATCH 55/70] refactor: rename all packages to com.sap.cds.feature.documentai.* + enable debug logs --- README.md | 5 +--- .../srv/src/main/resources/application.yaml | 4 +++ docs/architecture.md | 14 +++++------ .../DocumentAiServiceConfiguration.java | 18 ++++++------- .../handlers/DocumentSubmissionHandler.java | 6 ++--- .../handlers/ExtractionPollingHandler.java | 10 ++++---- .../DefaultDocumentAiProcessingService.java | 8 +++--- .../service/DocumentAiProcessingService.java | 14 +++++------ .../service/ExtractionService.java | 6 ++--- .../service/ExtractionServiceImpl.java | 25 ++++++++++--------- .../documentai}/service/ExtractionStatus.java | 4 +-- .../client/DefaultDocumentAiClient.java | 10 ++++---- .../service}/client/DocumentAiClient.java | 14 +++++------ .../ConcurrentJobUpdateException.java | 2 +- .../exceptions/DocumentAiException.java | 2 +- .../IllegalStatusTransitionException.java | 5 ++-- .../service/model/DocumentInput.java | 2 +- .../service/model/ExtractionData.java | 2 +- .../service/model/ExtractionResult.java | 6 ++--- .../utils/StatusTransitionValidator.java | 6 ++--- ...s.services.runtime.CdsRuntimeConfiguration | 2 +- .../DocumentAiServiceConfigurationTest.java | 10 ++++---- .../DocumentSubmissionHandlerTest.java | 7 +++--- .../ExtractionPollingHandlerTest.java | 10 ++++---- ...efaultDocumentAiProcessingServiceTest.java | 8 +++--- .../service/ExtractionServiceImplTest.java | 8 +++--- .../client/DefaultDocumentAiClientTest.java | 11 ++++---- .../service/exceptions/ExceptionsTest.java | 2 +- .../utils/StatusTransitionValidatorTest.java | 4 +-- 29 files changed, 113 insertions(+), 112 deletions(-) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/configuration/DocumentAiServiceConfiguration.java (92%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/handlers/DocumentSubmissionHandler.java (93%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/handlers/ExtractionPollingHandler.java (96%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/DefaultDocumentAiProcessingService.java (83%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/DocumentAiProcessingService.java (63%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/ExtractionService.java (91%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/ExtractionServiceImpl.java (89%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/ExtractionStatus.java (91%) rename sap-document-ai/src/main/java/com/sap/cds/{service/documentai => feature/documentai/service}/client/DefaultDocumentAiClient.java (94%) rename sap-document-ai/src/main/java/com/sap/cds/{service/documentai => feature/documentai/service}/client/DocumentAiClient.java (66%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/exceptions/ConcurrentJobUpdateException.java (92%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/exceptions/DocumentAiException.java (97%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/exceptions/IllegalStatusTransitionException.java (71%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/model/DocumentInput.java (92%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/model/ExtractionData.java (91%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/model/ExtractionResult.java (81%) rename sap-document-ai/src/main/java/com/sap/cds/{ => feature/documentai}/service/utils/StatusTransitionValidator.java (87%) rename sap-document-ai/src/test/java/com/sap/cds/{ => feature/documentai}/configuration/DocumentAiServiceConfigurationTest.java (92%) rename sap-document-ai/src/test/java/com/sap/cds/{ => feature/documentai/handlers}/DocumentSubmissionHandlerTest.java (94%) rename sap-document-ai/src/test/java/com/sap/cds/{ => feature/documentai}/handlers/ExtractionPollingHandlerTest.java (95%) rename sap-document-ai/src/test/java/com/sap/cds/{ => feature/documentai}/service/DefaultDocumentAiProcessingServiceTest.java (89%) rename sap-document-ai/src/test/java/com/sap/cds/{ => feature/documentai}/service/ExtractionServiceImplTest.java (96%) rename sap-document-ai/src/test/java/com/sap/cds/{service/documentai => feature/documentai/service}/client/DefaultDocumentAiClientTest.java (95%) rename sap-document-ai/src/test/java/com/sap/cds/{ => feature/documentai}/service/exceptions/ExceptionsTest.java (97%) rename sap-document-ai/src/test/java/com/sap/cds/{ => feature/documentai}/service/utils/StatusTransitionValidatorTest.java (93%) diff --git a/README.md b/README.md index cc4378e..3c9095a 100644 --- a/README.md +++ b/README.md @@ -359,10 +359,7 @@ To enable debug-level logging for the plugin, add the following to `application. ```yaml logging: level: - com.sap.cds.handlers.DocumentSubmissionHandler: DEBUG - com.sap.cds.handlers.ExtractionPollingHandler: DEBUG - com.sap.cds.service.ExtractionServiceImpl: DEBUG - com.sap.cds.service.documentai.client.DefaultDocumentAiClient: DEBUG + com.sap.cds.feature.documentai: DEBUG ``` --- ## References diff --git a/bookshop/srv/src/main/resources/application.yaml b/bookshop/srv/src/main/resources/application.yaml index 5334145..bde5da3 100644 --- a/bookshop/srv/src/main/resources/application.yaml +++ b/bookshop/srv/src/main/resources/application.yaml @@ -33,3 +33,7 @@ cds: document-ai: polling: interval-seconds: 5 + +logging: + level: + com.sap.cds.feature.documentai: DEBUG diff --git a/docs/architecture.md b/docs/architecture.md index c2f4a25..735d359 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,13 +54,13 @@ The plugin is implemented in the `sap-document-ai` module. The following Java pa | Package | Description | |---|---| -| `com.sap.cds.configuration` | Bootstraps all plugin components and registers them with the CDS runtime at startup | -| `com.sap.cds.handlers` | CDS event handlers for document submission and outbox-driven polling | -| `com.sap.cds.service` | Core extraction service, processing service, status enum, and transition validator | -| `com.sap.cds.service.documentai.client` | HTTP client abstraction for the DIE REST API | -| `com.sap.cds.service.model` | Immutable value objects used as internal data transfer types | -| `com.sap.cds.service.exceptions` | Typed exceptions for error classification | -| `com.sap.cds.service.utils` | Utility classes | +| `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 diff --git a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java similarity index 92% rename from sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java index 11e3e8b..3691fb3 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/configuration/DocumentAiServiceConfiguration.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java @@ -1,15 +1,15 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.configuration; - -import com.sap.cds.handlers.DocumentSubmissionHandler; -import com.sap.cds.handlers.ExtractionPollingHandler; -import com.sap.cds.service.DefaultDocumentAiProcessingService; -import com.sap.cds.service.DocumentAiProcessingService; -import com.sap.cds.service.ExtractionServiceImpl; -import com.sap.cds.service.documentai.client.DefaultDocumentAiClient; -import com.sap.cds.service.documentai.client.DocumentAiClient; +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; diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java similarity index 93% rename from sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java index 0ece9a6..08b943e 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/DocumentSubmissionHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java @@ -1,12 +1,12 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.handlers; +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.service.ExtractionService; -import com.sap.cds.service.model.ExtractionResult; +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; diff --git a/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java similarity index 96% rename from sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java index bb973b3..42b8381 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/handlers/ExtractionPollingHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java @@ -1,18 +1,18 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.handlers; +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.service.ExtractionService; -import com.sap.cds.service.ExtractionStatus; -import com.sap.cds.service.documentai.client.DocumentAiClient; -import com.sap.cds.service.model.ExtractionData; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java similarity index 83% rename from sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java index 5fa2f92..c1050cc 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DefaultDocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java @@ -1,11 +1,11 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.feature.documentai.service; -import com.sap.cds.service.documentai.client.DocumentAiClient; -import com.sap.cds.service.exceptions.DocumentAiException; -import com.sap.cds.service.model.DocumentInput; +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}. diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java similarity index 63% rename from sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java index 973e379..18c0b18 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/DocumentAiProcessingService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java @@ -1,16 +1,16 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.feature.documentai.service; -import com.sap.cds.service.model.DocumentInput; +import com.sap.cds.feature.documentai.service.model.DocumentInput; /** * Abstraction over the Document AI (DIE) submission layer. * - *

Decouples {@link com.sap.cds.service.ExtractionServiceImpl} from the concrete HTTP client so - * the service can remain operational (returning {@code PENDING} jobs) when no DIE binding is - * configured. + *

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 { @@ -27,8 +27,8 @@ public interface DocumentAiProcessingService { * @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.service.exceptions.DocumentAiException if submission or response parsing - * fails + * @throws com.sap.cds.feature.documentai.service.exceptions.DocumentAiException if submission or + * response parsing fails */ String processDocument(String jobId, DocumentInput documentInput); } diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java similarity index 91% rename from sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java index 1863c74..095010d 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionService.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java @@ -1,10 +1,10 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.feature.documentai.service; -import com.sap.cds.service.exceptions.IllegalStatusTransitionException; -import com.sap.cds.service.model.ExtractionResult; +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; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java similarity index 89% rename from sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java index a27782e..eb8e109 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java @@ -1,23 +1,23 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.feature.documentai.service; -import static com.sap.cds.handlers.ExtractionPollingHandler.*; -import static com.sap.cds.service.ExtractionStatus.*; +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.service.exceptions.ConcurrentJobUpdateException; -import com.sap.cds.service.exceptions.IllegalStatusTransitionException; -import com.sap.cds.service.model.DocumentInput; -import com.sap.cds.service.model.ExtractionResult; -import com.sap.cds.service.model.ExtractionResult.Status; -import com.sap.cds.service.utils.StatusTransitionValidator; import com.sap.cds.services.ServiceDelegator; import com.sap.cds.services.outbox.OutboxMessage; import com.sap.cds.services.outbox.OutboxService; @@ -43,7 +43,7 @@ * *

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.service.exceptions.ConcurrentJobUpdateException}. + * com.sap.cds.feature.documentai.service.exceptions.ConcurrentJobUpdateException}. */ public class ExtractionServiceImpl extends ServiceDelegator implements ExtractionService { @@ -61,8 +61,9 @@ public ExtractionServiceImpl() { /** * Injects runtime dependencies after Spring/CDS wiring is complete. * - *

Called from {@link com.sap.cds.configuration.DocumentAiServiceConfiguration} once all - * dependent services are resolved from the service catalog. + *

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 diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java similarity index 91% rename from sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java index db9a4ce..bccf52d 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/ExtractionStatus.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java @@ -1,13 +1,13 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.feature.documentai.service; /** * Lifecycle statuses for a document extraction job. * *

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

  *   PENDING → SUBMITTED | FAILED
diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java
similarity index 94%
rename from sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java
rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java
index dd9c17d..5fc99bc 100644
--- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClient.java
+++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java
@@ -1,14 +1,14 @@
 /*
 * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors.
 */
-package com.sap.cds.service.documentai.client;
+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.service.exceptions.DocumentAiException;
-import com.sap.cds.service.model.DocumentInput;
-import com.sap.cds.service.model.ExtractionData;
+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;
@@ -36,7 +36,7 @@
  * 
  *
  * 

All HTTP failures and unexpected response shapes are wrapped in the appropriate {@link - * com.sap.cds.service.exceptions.DocumentAiException} subclass. + * com.sap.cds.feature.documentai.service.exceptions.DocumentAiException} subclass. */ public class DefaultDocumentAiClient implements DocumentAiClient { diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java similarity index 66% rename from sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java index aa7a53e..295699f 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/documentai/client/DocumentAiClient.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java @@ -1,10 +1,10 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.documentai.client; +package com.sap.cds.feature.documentai.service.client; -import com.sap.cds.service.model.DocumentInput; -import com.sap.cds.service.model.ExtractionData; +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. @@ -19,8 +19,8 @@ public interface DocumentAiClient { * * @param documentInput the document content and metadata * @return the DIE-assigned job ID for the submitted document - * @throws com.sap.cds.service.exceptions.DocumentAiException if the HTTP call fails or the - * response cannot be parsed + * @throws com.sap.cds.feature.documentai.service.exceptions.DocumentAiException if the HTTP call + * fails or the response cannot be parsed */ String submitDocument(DocumentInput documentInput); @@ -29,8 +29,8 @@ public interface DocumentAiClient { * * @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.service.exceptions.DocumentAiException if the HTTP call fails or the - * response cannot be parsed + * @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/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java similarity index 92% rename from sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java index 73ec1a4..7dd26cb 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/ConcurrentJobUpdateException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.exceptions; +package com.sap.cds.feature.documentai.service.exceptions; /** * Thrown when an optimistic-lock update of an extraction job detects that another thread or process diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java similarity index 97% rename from sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java index e351c27..27f46f3 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/DocumentAiException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.exceptions; +package com.sap.cds.feature.documentai.service.exceptions; /** * Base exception for all errors originating from interaction with the Document AI (DIE) service. diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java similarity index 71% rename from sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java index 834ebc2..4d3ba04 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/exceptions/IllegalStatusTransitionException.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java @@ -1,11 +1,12 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.exceptions; +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.service.utils.StatusTransitionValidator}. + * by the state machine defined in {@link + * com.sap.cds.feature.documentai.service.utils.StatusTransitionValidator}. */ public class IllegalStatusTransitionException extends RuntimeException { diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java similarity index 92% rename from sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java index 7c39f4e..a89e467 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/DocumentInput.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.model; +package com.sap.cds.feature.documentai.service.model; import java.io.InputStream; diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java similarity index 91% rename from sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java index a44d1e2..ff584b3 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionData.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.model; +package com.sap.cds.feature.documentai.service.model; /** * Immutable value object holding the raw poll response returned by the DIE service for a job. diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java similarity index 81% rename from sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java index db569d7..4da18f6 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/model/ExtractionResult.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java @@ -1,12 +1,12 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.model; +package com.sap.cds.feature.documentai.service.model; /** * Immutable value object returned by {@link - * com.sap.cds.service.ExtractionService#triggerExtraction} to convey the immediate outcome of a - * submission attempt. + * 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}) diff --git a/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java similarity index 87% rename from sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java rename to sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java index c9cfc1e..775a890 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/service/utils/StatusTransitionValidator.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java @@ -1,11 +1,11 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.utils; +package com.sap.cds.feature.documentai.service.utils; -import static com.sap.cds.service.ExtractionStatus.*; +import static com.sap.cds.feature.documentai.service.ExtractionStatus.*; -import com.sap.cds.service.ExtractionStatus; +import com.sap.cds.feature.documentai.service.ExtractionStatus; /** * Utility class that enforces the allowed state-machine transitions for {@link ExtractionStatus}. diff --git a/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration index 9362562..4e66c37 100644 --- a/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration +++ b/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -1 +1 @@ -com.sap.cds.configuration.DocumentAiServiceConfiguration +com.sap.cds.feature.documentai.configuration.DocumentAiServiceConfiguration diff --git a/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java similarity index 92% rename from sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java rename to sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java index 6f031bd..eb34dca 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/configuration/DocumentAiServiceConfigurationTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java @@ -1,17 +1,17 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.configuration; +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.handlers.DocumentSubmissionHandler; -import com.sap.cds.service.DefaultDocumentAiProcessingService; -import com.sap.cds.service.ExtractionServiceImpl; -import com.sap.cds.service.documentai.client.DocumentAiClient; +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; diff --git a/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java similarity index 94% rename from sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java rename to sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java index 85d8e1e..cd7db2b 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/DocumentSubmissionHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds; +package com.sap.cds.feature.documentai.handlers; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -9,9 +9,8 @@ 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.handlers.DocumentSubmissionHandler; -import com.sap.cds.service.ExtractionService; -import com.sap.cds.service.model.ExtractionResult; +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; diff --git a/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java similarity index 95% rename from sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java rename to sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java index 7279be1..352e847 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/handlers/ExtractionPollingHandlerTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.handlers; +package com.sap.cds.feature.documentai.handlers; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -10,11 +10,11 @@ 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.service.ExtractionService; -import com.sap.cds.service.ExtractionStatus; -import com.sap.cds.service.documentai.client.DocumentAiClient; -import com.sap.cds.service.model.ExtractionData; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.outbox.OutboxMessageEventContext; diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java similarity index 89% rename from sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java rename to sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java index 8834f0c..649a8d5 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/DefaultDocumentAiProcessingServiceTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java @@ -1,13 +1,13 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.feature.documentai.service; import static org.mockito.ArgumentMatchers.any; -import com.sap.cds.service.documentai.client.DocumentAiClient; -import com.sap.cds.service.exceptions.DocumentAiException; -import com.sap.cds.service.model.DocumentInput; +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; diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java similarity index 96% rename from sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java rename to sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java index 1b292c8..d7912a3 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/ExtractionServiceImplTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java @@ -1,20 +1,20 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service; +package com.sap.cds.feature.documentai.service; -import static com.sap.cds.service.ExtractionStatus.*; +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.service.exceptions.IllegalStatusTransitionException; -import com.sap.cds.service.model.ExtractionResult; import com.sap.cds.services.outbox.OutboxService; import com.sap.cds.services.outbox.Schedule; import com.sap.cds.services.persistence.PersistenceService; diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java similarity index 95% rename from sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java rename to sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java index 098129f..5d1c319 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/documentai/client/DefaultDocumentAiClientTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java @@ -1,16 +1,16 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.documentai.client; +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.service.exceptions.DocumentAiException; -import com.sap.cds.service.model.DocumentInput; -import com.sap.cds.service.model.ExtractionData; +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; @@ -181,8 +181,7 @@ void submitDocumentUsesEmptyJsonWhenOptionsIsNull() throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); requestCaptor.getValue().getEntity().writeTo(buffer); String requestBody = buffer.toString(); - assertThat(requestBody).contains("{}"); - assertThat(requestBody).contains("options"); + assertThat(requestBody).contains("{}").contains("options"); } @SuppressWarnings("unchecked") diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java similarity index 97% rename from sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java rename to sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java index 06ca11e..36ec33f 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/exceptions/ExceptionsTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.exceptions; +package com.sap.cds.feature.documentai.service.exceptions; import static org.assertj.core.api.Assertions.assertThat; diff --git a/sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java similarity index 93% rename from sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java rename to sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java index a81c2e2..b54bce5 100644 --- a/sap-document-ai/src/test/java/com/sap/cds/service/utils/StatusTransitionValidatorTest.java +++ b/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java @@ -1,9 +1,9 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. */ -package com.sap.cds.service.utils; +package com.sap.cds.feature.documentai.service.utils; -import static com.sap.cds.service.ExtractionStatus.*; +import static com.sap.cds.feature.documentai.service.ExtractionStatus.*; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; From 150b3aacfdb1846e684bcd610629467b28724703 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:10:06 +0200 Subject: [PATCH 56/70] docs: remove integration tests from documentation --- docs/architecture.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c2f4a25..6d2067e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,6 @@ - [Status State Machine](#status-state-machine) - [Tests](#tests) - [Unit Tests](#unit-tests) - - [Integration Tests](#integration-tests) - [Quality Tools](#quality-tools) --- @@ -43,7 +42,6 @@ | `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 | -| `integration-tests` | Integration test module | | `docs` | Design and architecture documentation | --- @@ -215,10 +213,6 @@ Unit tests are located in `sap-document-ai/src/test/java`. Each production class 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). -### Integration Tests - -Integration tests are located in the `integration-tests` module. They run a full Spring Boot application against an in-memory H2 database and verify end-to-end behaviour including CDS model deployment, event dispatch, and outbox scheduling. - --- ## Quality Tools From 0f9890f000af5feb9c700e64fbfa2bd19f4493ca Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:26:48 +0200 Subject: [PATCH 57/70] docs: remove integration tests from documentation --- docs/architecture.md | 6 ------ .../documentai/handlers/ExtractionPollingHandler.java | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 735d359..42a8943 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,6 @@ - [Status State Machine](#status-state-machine) - [Tests](#tests) - [Unit Tests](#unit-tests) - - [Integration Tests](#integration-tests) - [Quality Tools](#quality-tools) --- @@ -43,7 +42,6 @@ | `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 | -| `integration-tests` | Integration test module | | `docs` | Design and architecture documentation | --- @@ -215,10 +213,6 @@ Unit tests are located in `sap-document-ai/src/test/java`. Each production class 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). -### Integration Tests - -Integration tests are located in the `integration-tests` module. They run a full Spring Boot application against an in-memory H2 database and verify end-to-end behaviour including CDS model deployment, event dispatch, and outbox scheduling. - --- ## Quality Tools diff --git a/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java index 42b8381..f34fc5f 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java @@ -40,7 +40,7 @@ *

  • Persists the new status via {@link ExtractionService#updateExtractionResult}. *
  • When a job reaches {@code DONE}, emits a {@code DocumentExtractionResult} event on the * {@code DocumentAiService} so consumer handlers can react. - *
  • If jobs remain active, re-schedules itself via the outbox after {@link #POLL_DELAY}. + *
  • 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 From 5ca94da28c3dcd623bb4e10c7cb1685041da7995 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:30:13 +0200 Subject: [PATCH 58/70] chore: apply spotless --- .../feature/documentai/handlers/ExtractionPollingHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java index f34fc5f..dd87b19 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java @@ -40,7 +40,8 @@ *

  • Persists the new status via {@link ExtractionService#updateExtractionResult}. *
  • When a job reaches {@code DONE}, emits a {@code DocumentExtractionResult} event on the * {@code DocumentAiService} so consumer handlers can react. - *
  • If jobs remain active, re-schedules itself via the outbox after {@link #DEFAULT_POLL_INTERVAL_SECONDS} seconds. + *
  • 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 From eab5d3999ffcc3d31cc3060e38e6d91cf019822b Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:37:10 +0200 Subject: [PATCH 59/70] tests: add integration tests refactor: rename integration test files refactor: turn off logs --- README.md | 27 +++ integration-tests/.gitignore | 1 + integration-tests/package.json | 12 ++ integration-tests/pom.xml | 158 ++++++++++++++++++ .../integrationtest/Application.java | 14 ++ .../src/main/resources/application.yaml | 9 + .../AbstractDocumentAiITest.java | 69 ++++++++ .../DocumentSubmissionITest.java | 93 +++++++++++ .../integrationtest/EventEmissionITest.java | 52 ++++++ .../integrationtest/ExtractionErrorITest.java | 53 ++++++ .../ExtractionLifecycleITest.java | 151 +++++++++++++++++ .../ExtractionResultCaptureHandler.java | 36 ++++ .../integrationtest/PluginLoadITest.java | 27 +++ .../src/test/resources/logback-test.xml | 15 ++ .../service/ExtractionServiceImpl.java | 7 +- 15 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 integration-tests/.gitignore create mode 100644 integration-tests/package.json create mode 100644 integration-tests/pom.xml create mode 100644 integration-tests/src/main/java/com/sap/cds/feature/documentai/integrationtest/Application.java create mode 100644 integration-tests/src/main/resources/application.yaml create mode 100644 integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java create mode 100644 integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java create mode 100644 integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java create mode 100644 integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java create mode 100644 integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java create mode 100644 integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java create mode 100644 integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java create mode 100644 integration-tests/src/test/resources/logback-test.xml diff --git a/README.md b/README.md index 3c9095a..a33c068 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A CAP Java plugin that integrates [SAP Document AI](https://help.sap.com/docs/do - [Monitoring and Logging](#monitoring-and-logging) - [References](#references) - [Support, Feedback, Contributing](#support-feedback-contributing) +- [Integration Tests](#integration-tests) --- @@ -377,3 +378,29 @@ logging: - 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/integration-tests/.gitignore b/integration-tests/.gitignore new file mode 100644 index 0000000..a0c4db2 --- /dev/null +++ b/integration-tests/.gitignore @@ -0,0 +1 @@ +srv/ diff --git a/integration-tests/package.json b/integration-tests/package.json new file mode 100644 index 0000000..d54874a --- /dev/null +++ b/integration-tests/package.json @@ -0,0 +1,12 @@ +{ + "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/integration-tests/pom.xml b/integration-tests/pom.xml new file mode 100644 index 0000000..15ed264 --- /dev/null +++ b/integration-tests/pom.xml @@ -0,0 +1,158 @@ + + + 4.0.0 + + com.sap.cds + sap-document-ai-integration-tests + 1.0-SNAPSHOT + jar + sap-document-ai-integration-tests + + + 17 + + 4.9.1 + 3.5.15 + UTF-8 + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + + com.sap.cds + sap-document-ai + 1.0-SNAPSHOT + + + + + com.sap.cds + cds-starter-spring-boot + + + + com.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + src/main/resources + + + srv/src/main/resources + + + + + maven-compiler-plugin + 3.14.1 + + ${jdk.version} + + + + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + cds.install-node + + install-node + + + + cds.resolve + + resolve + + + + cds.build + + cds + + + + build --for java + deploy --to h2 --dry --out "${project.build.outputDirectory}/schema-h2.sql" + + + + + + + + + maven-surefire-plugin + 3.5.4 + + + **/*ITest.java + + + + + + + maven-failsafe-plugin + 3.5.4 + + + + integration-test + verify + + + + **/*ITest.java + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + true + + + + + diff --git a/integration-tests/src/main/java/com/sap/cds/feature/documentai/integrationtest/Application.java b/integration-tests/src/main/java/com/sap/cds/feature/documentai/integrationtest/Application.java new file mode 100644 index 0000000..4bc8cb4 --- /dev/null +++ b/integration-tests/src/main/java/com/sap/cds/feature/documentai/integrationtest/Application.java @@ -0,0 +1,14 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.feature.documentai.integrationtest; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/integration-tests/src/main/resources/application.yaml b/integration-tests/src/main/resources/application.yaml new file mode 100644 index 0000000..0d53ee5 --- /dev/null +++ b/integration-tests/src/main/resources/application.yaml @@ -0,0 +1,9 @@ +spring: + sql: + init: + platform: h2 + +cds: + data-source: + auto-config: + enabled: false diff --git a/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java new file mode 100644 index 0000000..f8fc259 --- /dev/null +++ b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java @@ -0,0 +1,69 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-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 AbstractDocumentAiITest { + + @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/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java new file mode 100644 index 0000000..0c7f375 --- /dev/null +++ b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java @@ -0,0 +1,93 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-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 DocumentSubmissionITest extends AbstractDocumentAiITest { + + @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/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java new file mode 100644 index 0000000..ee0261e --- /dev/null +++ b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java @@ -0,0 +1,52 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-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 com.sap.cds.feature.documentai.service.model.ExtractionResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class EventEmissionITest extends AbstractDocumentAiITest { + + 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/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java new file mode 100644 index 0000000..43ffc8d --- /dev/null +++ b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java @@ -0,0 +1,53 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. +*/ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; +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 com.sap.cds.feature.documentai.service.model.ExtractionResult; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ExtractionErrorITest extends AbstractDocumentAiITest { + + private static final String DIE_JOB_ID = "die-1"; + + @Autowired + ExtractionService extractionService; + + @Test + void submissionWithoutDieBindingCreatesPendingJob() { + ExtractionResult result = + extractionService.triggerExtraction( + "invoice.pdf", "application/pdf", null, null, "tenant-1"); + + assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); + assertThat(result.internalJobId()).isNotNull(); + assertThat(result.documentAiJobId()).isNull(); + } + + @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/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java new file mode 100644 index 0000000..0364365 --- /dev/null +++ b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java @@ -0,0 +1,151 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-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 ExtractionLifecycleITest extends AbstractDocumentAiITest { + + 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()); + } + + 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/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java new file mode 100644 index 0000000..3aaa606 --- /dev/null +++ b/integration-tests/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-feature-sap-document-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/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java new file mode 100644 index 0000000..0766900 --- /dev/null +++ b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java @@ -0,0 +1,27 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-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 PluginLoadITest extends AbstractDocumentAiITest { + + @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/src/test/resources/logback-test.xml b/integration-tests/src/test/resources/logback-test.xml new file mode 100644 index 0000000..366a1bf --- /dev/null +++ b/integration-tests/src/test/resources/logback-test.xml @@ -0,0 +1,15 @@ + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java index eb8e109..4825a91 100644 --- a/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java +++ b/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java @@ -198,8 +198,11 @@ private void updateExtractionJob( Result updateResult = persistenceService.run( Update.entity(ExtractionJob_.class) - .byId(jobId) - .where(j -> j.get(ExtractionJob.STATUS).eq(currentStatus.name())) + .where( + j -> + j.get(ExtractionJob.ID) + .eq(jobId) + .and(j.get(ExtractionJob.STATUS).eq(currentStatus.name()))) .entry(extractionJob)); if (updateResult.rowCount() == 0) { From 475ca0177b4d78582b163b4ffd73fb10d4e8f2a9 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:55:34 +0200 Subject: [PATCH 60/70] fix: review fixes --- .../integrationtest/ExtractionErrorITest.java | 13 ------------- .../integrationtest/ExtractionLifecycleITest.java | 4 ++++ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java index 43ffc8d..bc6041d 100644 --- a/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java +++ b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java @@ -3,13 +3,11 @@ */ package com.sap.cds.feature.documentai.integrationtest; -import static org.assertj.core.api.Assertions.assertThat; 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 com.sap.cds.feature.documentai.service.model.ExtractionResult; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -20,17 +18,6 @@ class ExtractionErrorITest extends AbstractDocumentAiITest { @Autowired ExtractionService extractionService; - @Test - void submissionWithoutDieBindingCreatesPendingJob() { - ExtractionResult result = - extractionService.triggerExtraction( - "invoice.pdf", "application/pdf", null, null, "tenant-1"); - - assertThat(result.status()).isEqualTo(ExtractionResult.Status.PENDING); - assertThat(result.internalJobId()).isNotNull(); - assertThat(result.documentAiJobId()).isNull(); - } - @Test void invalidStatusTransitionThrows() { String jobId = diff --git a/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java index 0364365..f3ac113 100644 --- a/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java +++ b/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java @@ -137,6 +137,10 @@ void pollCycleContinuesWhenOneJobFails() { 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) { From 6da248dc6152530e9456ba7c5cffdaee4929e72d Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 13:52:36 +0200 Subject: [PATCH 61/70] refactor(document-ai): reorganize imported tree into cds-ai layout - Hoist the plugin Maven module up: sap-document-ai/{pom.xml,src} -> cds-feature-sap-document-ai/{pom.xml,src} - Move imported bookshop sample to samples/document-ai-bookshop/ - Move imported integration test Java sources into integration-tests/spring/ under com.sap.cds.feature.documentai.integrationtest (Application, config and remaining files are handled in later commits). Structural-only change; no pom or Java content is modified in this commit. --- cds-feature-sap-document-ai/{sap-document-ai => }/pom.xml | 0 .../documentai/configuration/DocumentAiServiceConfiguration.java | 0 .../feature/documentai/handlers/DocumentSubmissionHandler.java | 0 .../cds/feature/documentai/handlers/ExtractionPollingHandler.java | 0 .../documentai/service/DefaultDocumentAiProcessingService.java | 0 .../feature/documentai/service/DocumentAiProcessingService.java | 0 .../com/sap/cds/feature/documentai/service/ExtractionService.java | 0 .../sap/cds/feature/documentai/service/ExtractionServiceImpl.java | 0 .../com/sap/cds/feature/documentai/service/ExtractionStatus.java | 0 .../documentai/service/client/DefaultDocumentAiClient.java | 0 .../cds/feature/documentai/service/client/DocumentAiClient.java | 0 .../service/exceptions/ConcurrentJobUpdateException.java | 0 .../documentai/service/exceptions/DocumentAiException.java | 0 .../service/exceptions/IllegalStatusTransitionException.java | 0 .../sap/cds/feature/documentai/service/model/DocumentInput.java | 0 .../sap/cds/feature/documentai/service/model/ExtractionData.java | 0 .../cds/feature/documentai/service/model/ExtractionResult.java | 0 .../documentai/service/utils/StatusTransitionValidator.java | 0 .../services/com.sap.cds.services.runtime.CdsRuntimeConfiguration | 0 .../cds/com.sap.cds/sap-document-ai/document-ai-service.cds | 0 .../resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds | 0 .../src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds | 0 .../configuration/DocumentAiServiceConfigurationTest.java | 0 .../documentai/handlers/DocumentSubmissionHandlerTest.java | 0 .../feature/documentai/handlers/ExtractionPollingHandlerTest.java | 0 .../service/DefaultDocumentAiProcessingServiceTest.java | 0 .../cds/feature/documentai/service/ExtractionServiceImplTest.java | 0 .../documentai/service/client/DefaultDocumentAiClientTest.java | 0 .../cds/feature/documentai/service/exceptions/ExceptionsTest.java | 0 .../documentai/service/utils/StatusTransitionValidatorTest.java | 0 .../documentai/integrationtest/AbstractDocumentAiITest.java | 0 .../documentai/integrationtest/DocumentSubmissionITest.java | 0 .../feature/documentai/integrationtest/EventEmissionITest.java | 0 .../feature/documentai/integrationtest/ExtractionErrorITest.java | 0 .../documentai/integrationtest/ExtractionLifecycleITest.java | 0 .../integrationtest/ExtractionResultCaptureHandler.java | 0 .../cds/feature/documentai/integrationtest/PluginLoadITest.java | 0 .../bookshop => samples/document-ai-bookshop}/.cdsrc.json | 0 .../bookshop => samples/document-ai-bookshop}/.gitignore | 0 .../document-ai-bookshop}/app/_i18n/i18n.properties | 0 .../document-ai-bookshop}/app/_i18n/i18n_de.properties | 0 .../document-ai-bookshop}/app/admin-books/fiori-service.cds | 0 .../document-ai-bookshop}/app/admin-books/webapp/Component.js | 0 .../app/admin-books/webapp/i18n/i18n.properties | 0 .../app/admin-books/webapp/i18n/i18n_de.properties | 0 .../document-ai-bookshop}/app/admin-books/webapp/manifest.json | 0 .../document-ai-bookshop}/app/appconfig/fioriSandboxConfig.json | 0 .../document-ai-bookshop}/app/browse/fiori-service.cds | 0 .../document-ai-bookshop}/app/browse/webapp/Component.js | 0 .../document-ai-bookshop}/app/browse/webapp/i18n/i18n.properties | 0 .../app/browse/webapp/i18n/i18n_de.properties | 0 .../document-ai-bookshop}/app/browse/webapp/manifest.json | 0 .../bookshop => samples/document-ai-bookshop}/app/common.cds | 0 .../bookshop => samples/document-ai-bookshop}/app/index.html | 0 .../bookshop => samples/document-ai-bookshop}/app/services.cds | 0 .../document-ai-bookshop}/db/data/sap.capire.bookshop-Authors.csv | 0 .../document-ai-bookshop}/db/data/sap.capire.bookshop-Books.csv | 0 .../db/data/sap.capire.bookshop-Books_texts.csv | 0 .../document-ai-bookshop}/db/data/sap.capire.bookshop-Genres.csv | 0 .../bookshop => samples/document-ai-bookshop}/db/schema.cds | 0 .../bookshop => samples/document-ai-bookshop}/package-lock.json | 0 .../bookshop => samples/document-ai-bookshop}/package.json | 0 .../bookshop => samples/document-ai-bookshop}/pom.xml | 0 .../document-ai-bookshop}/srv/admin-service.cds | 0 .../bookshop => samples/document-ai-bookshop}/srv/attachments.cds | 0 .../bookshop => samples/document-ai-bookshop}/srv/cat-service.cds | 0 .../bookshop => samples/document-ai-bookshop}/srv/pom.xml | 0 .../srv/src/main/java/customer/bookshop/Application.java | 0 .../java/customer/bookshop/handlers/CatalogServiceHandler.java | 0 .../customer/bookshop/handlers/DocumentExtractionHandler.java | 0 .../bookshop/handlers/DocumentExtractionResultHandler.java | 0 .../document-ai-bookshop}/srv/src/main/resources/application.yaml | 0 .../customer/bookshop/handlers/CatalogServiceHandlerTest.java | 0 73 files changed, 0 insertions(+), 0 deletions(-) rename cds-feature-sap-document-ai/{sap-document-ai => }/pom.xml (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java (100%) rename cds-feature-sap-document-ai/{sap-document-ai => }/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java (100%) rename {cds-feature-sap-document-ai/integration-tests => integration-tests/spring}/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java (100%) rename {cds-feature-sap-document-ai/integration-tests => integration-tests/spring}/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java (100%) rename {cds-feature-sap-document-ai/integration-tests => integration-tests/spring}/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java (100%) rename {cds-feature-sap-document-ai/integration-tests => integration-tests/spring}/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java (100%) rename {cds-feature-sap-document-ai/integration-tests => integration-tests/spring}/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java (100%) rename {cds-feature-sap-document-ai/integration-tests => integration-tests/spring}/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java (100%) rename {cds-feature-sap-document-ai/integration-tests => integration-tests/spring}/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/.cdsrc.json (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/.gitignore (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/_i18n/i18n.properties (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/_i18n/i18n_de.properties (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/admin-books/fiori-service.cds (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/admin-books/webapp/Component.js (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/admin-books/webapp/i18n/i18n.properties (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/admin-books/webapp/i18n/i18n_de.properties (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/admin-books/webapp/manifest.json (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/appconfig/fioriSandboxConfig.json (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/browse/fiori-service.cds (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/browse/webapp/Component.js (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/browse/webapp/i18n/i18n.properties (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/browse/webapp/i18n/i18n_de.properties (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/browse/webapp/manifest.json (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/common.cds (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/index.html (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/app/services.cds (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/db/data/sap.capire.bookshop-Authors.csv (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/db/data/sap.capire.bookshop-Books.csv (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/db/data/sap.capire.bookshop-Books_texts.csv (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/db/data/sap.capire.bookshop-Genres.csv (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/db/schema.cds (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/package-lock.json (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/package.json (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/pom.xml (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/admin-service.cds (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/attachments.cds (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/cat-service.cds (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/pom.xml (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/src/main/java/customer/bookshop/Application.java (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/src/main/resources/application.yaml (100%) rename {cds-feature-sap-document-ai/bookshop => samples/document-ai-bookshop}/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java (100%) diff --git a/cds-feature-sap-document-ai/sap-document-ai/pom.xml b/cds-feature-sap-document-ai/pom.xml similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/pom.xml rename to cds-feature-sap-document-ai/pom.xml diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionService.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/ConcurrentJobUpdateException.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/DocumentAiException.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/exceptions/IllegalStatusTransitionException.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/DocumentInput.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionData.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/model/ExtractionResult.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java rename to cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidator.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration rename to cds-feature-sap-document-ai/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds rename to cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/document-ai-service.cds diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds rename to cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/extraction-job.cds diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds rename to cds-feature-sap-document-ai/src/main/resources/cds/com.sap.cds/sap-document-ai/index.cds diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java rename to cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java rename to cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java rename to cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java rename to cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java rename to cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java rename to cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java rename to cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/exceptions/ExceptionsTest.java diff --git a/cds-feature-sap-document-ai/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 similarity index 100% rename from cds-feature-sap-document-ai/sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java rename to cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/utils/StatusTransitionValidatorTest.java diff --git a/cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java similarity index 100% rename from cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java diff --git a/cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java similarity index 100% rename from cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java diff --git a/cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java similarity index 100% rename from cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java diff --git a/cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java similarity index 100% rename from cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java diff --git a/cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java similarity index 100% rename from cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java diff --git a/cds-feature-sap-document-ai/integration-tests/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 similarity index 100% rename from cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionResultCaptureHandler.java diff --git a/cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java similarity index 100% rename from cds-feature-sap-document-ai/integration-tests/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java diff --git a/cds-feature-sap-document-ai/bookshop/.cdsrc.json b/samples/document-ai-bookshop/.cdsrc.json similarity index 100% rename from cds-feature-sap-document-ai/bookshop/.cdsrc.json rename to samples/document-ai-bookshop/.cdsrc.json diff --git a/cds-feature-sap-document-ai/bookshop/.gitignore b/samples/document-ai-bookshop/.gitignore similarity index 100% rename from cds-feature-sap-document-ai/bookshop/.gitignore rename to samples/document-ai-bookshop/.gitignore diff --git a/cds-feature-sap-document-ai/bookshop/app/_i18n/i18n.properties b/samples/document-ai-bookshop/app/_i18n/i18n.properties similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/_i18n/i18n.properties rename to samples/document-ai-bookshop/app/_i18n/i18n.properties diff --git a/cds-feature-sap-document-ai/bookshop/app/_i18n/i18n_de.properties b/samples/document-ai-bookshop/app/_i18n/i18n_de.properties similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/_i18n/i18n_de.properties rename to samples/document-ai-bookshop/app/_i18n/i18n_de.properties diff --git a/cds-feature-sap-document-ai/bookshop/app/admin-books/fiori-service.cds b/samples/document-ai-bookshop/app/admin-books/fiori-service.cds similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/admin-books/fiori-service.cds rename to samples/document-ai-bookshop/app/admin-books/fiori-service.cds diff --git a/cds-feature-sap-document-ai/bookshop/app/admin-books/webapp/Component.js b/samples/document-ai-bookshop/app/admin-books/webapp/Component.js similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/admin-books/webapp/Component.js rename to samples/document-ai-bookshop/app/admin-books/webapp/Component.js diff --git a/cds-feature-sap-document-ai/bookshop/app/admin-books/webapp/i18n/i18n.properties b/samples/document-ai-bookshop/app/admin-books/webapp/i18n/i18n.properties similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/admin-books/webapp/i18n/i18n.properties rename to samples/document-ai-bookshop/app/admin-books/webapp/i18n/i18n.properties diff --git a/cds-feature-sap-document-ai/bookshop/app/admin-books/webapp/i18n/i18n_de.properties b/samples/document-ai-bookshop/app/admin-books/webapp/i18n/i18n_de.properties similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/admin-books/webapp/i18n/i18n_de.properties rename to samples/document-ai-bookshop/app/admin-books/webapp/i18n/i18n_de.properties diff --git a/cds-feature-sap-document-ai/bookshop/app/admin-books/webapp/manifest.json b/samples/document-ai-bookshop/app/admin-books/webapp/manifest.json similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/admin-books/webapp/manifest.json rename to samples/document-ai-bookshop/app/admin-books/webapp/manifest.json diff --git a/cds-feature-sap-document-ai/bookshop/app/appconfig/fioriSandboxConfig.json b/samples/document-ai-bookshop/app/appconfig/fioriSandboxConfig.json similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/appconfig/fioriSandboxConfig.json rename to samples/document-ai-bookshop/app/appconfig/fioriSandboxConfig.json diff --git a/cds-feature-sap-document-ai/bookshop/app/browse/fiori-service.cds b/samples/document-ai-bookshop/app/browse/fiori-service.cds similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/browse/fiori-service.cds rename to samples/document-ai-bookshop/app/browse/fiori-service.cds diff --git a/cds-feature-sap-document-ai/bookshop/app/browse/webapp/Component.js b/samples/document-ai-bookshop/app/browse/webapp/Component.js similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/browse/webapp/Component.js rename to samples/document-ai-bookshop/app/browse/webapp/Component.js diff --git a/cds-feature-sap-document-ai/bookshop/app/browse/webapp/i18n/i18n.properties b/samples/document-ai-bookshop/app/browse/webapp/i18n/i18n.properties similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/browse/webapp/i18n/i18n.properties rename to samples/document-ai-bookshop/app/browse/webapp/i18n/i18n.properties diff --git a/cds-feature-sap-document-ai/bookshop/app/browse/webapp/i18n/i18n_de.properties b/samples/document-ai-bookshop/app/browse/webapp/i18n/i18n_de.properties similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/browse/webapp/i18n/i18n_de.properties rename to samples/document-ai-bookshop/app/browse/webapp/i18n/i18n_de.properties diff --git a/cds-feature-sap-document-ai/bookshop/app/browse/webapp/manifest.json b/samples/document-ai-bookshop/app/browse/webapp/manifest.json similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/browse/webapp/manifest.json rename to samples/document-ai-bookshop/app/browse/webapp/manifest.json diff --git a/cds-feature-sap-document-ai/bookshop/app/common.cds b/samples/document-ai-bookshop/app/common.cds similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/common.cds rename to samples/document-ai-bookshop/app/common.cds diff --git a/cds-feature-sap-document-ai/bookshop/app/index.html b/samples/document-ai-bookshop/app/index.html similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/index.html rename to samples/document-ai-bookshop/app/index.html diff --git a/cds-feature-sap-document-ai/bookshop/app/services.cds b/samples/document-ai-bookshop/app/services.cds similarity index 100% rename from cds-feature-sap-document-ai/bookshop/app/services.cds rename to samples/document-ai-bookshop/app/services.cds diff --git a/cds-feature-sap-document-ai/bookshop/db/data/sap.capire.bookshop-Authors.csv b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Authors.csv similarity index 100% rename from cds-feature-sap-document-ai/bookshop/db/data/sap.capire.bookshop-Authors.csv rename to samples/document-ai-bookshop/db/data/sap.capire.bookshop-Authors.csv diff --git a/cds-feature-sap-document-ai/bookshop/db/data/sap.capire.bookshop-Books.csv b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books.csv similarity index 100% rename from cds-feature-sap-document-ai/bookshop/db/data/sap.capire.bookshop-Books.csv rename to samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books.csv diff --git a/cds-feature-sap-document-ai/bookshop/db/data/sap.capire.bookshop-Books_texts.csv b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books_texts.csv similarity index 100% rename from cds-feature-sap-document-ai/bookshop/db/data/sap.capire.bookshop-Books_texts.csv rename to samples/document-ai-bookshop/db/data/sap.capire.bookshop-Books_texts.csv diff --git a/cds-feature-sap-document-ai/bookshop/db/data/sap.capire.bookshop-Genres.csv b/samples/document-ai-bookshop/db/data/sap.capire.bookshop-Genres.csv similarity index 100% rename from cds-feature-sap-document-ai/bookshop/db/data/sap.capire.bookshop-Genres.csv rename to samples/document-ai-bookshop/db/data/sap.capire.bookshop-Genres.csv diff --git a/cds-feature-sap-document-ai/bookshop/db/schema.cds b/samples/document-ai-bookshop/db/schema.cds similarity index 100% rename from cds-feature-sap-document-ai/bookshop/db/schema.cds rename to samples/document-ai-bookshop/db/schema.cds diff --git a/cds-feature-sap-document-ai/bookshop/package-lock.json b/samples/document-ai-bookshop/package-lock.json similarity index 100% rename from cds-feature-sap-document-ai/bookshop/package-lock.json rename to samples/document-ai-bookshop/package-lock.json diff --git a/cds-feature-sap-document-ai/bookshop/package.json b/samples/document-ai-bookshop/package.json similarity index 100% rename from cds-feature-sap-document-ai/bookshop/package.json rename to samples/document-ai-bookshop/package.json diff --git a/cds-feature-sap-document-ai/bookshop/pom.xml b/samples/document-ai-bookshop/pom.xml similarity index 100% rename from cds-feature-sap-document-ai/bookshop/pom.xml rename to samples/document-ai-bookshop/pom.xml diff --git a/cds-feature-sap-document-ai/bookshop/srv/admin-service.cds b/samples/document-ai-bookshop/srv/admin-service.cds similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/admin-service.cds rename to samples/document-ai-bookshop/srv/admin-service.cds diff --git a/cds-feature-sap-document-ai/bookshop/srv/attachments.cds b/samples/document-ai-bookshop/srv/attachments.cds similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/attachments.cds rename to samples/document-ai-bookshop/srv/attachments.cds diff --git a/cds-feature-sap-document-ai/bookshop/srv/cat-service.cds b/samples/document-ai-bookshop/srv/cat-service.cds similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/cat-service.cds rename to samples/document-ai-bookshop/srv/cat-service.cds diff --git a/cds-feature-sap-document-ai/bookshop/srv/pom.xml b/samples/document-ai-bookshop/srv/pom.xml similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/pom.xml rename to samples/document-ai-bookshop/srv/pom.xml diff --git a/cds-feature-sap-document-ai/bookshop/srv/src/main/java/customer/bookshop/Application.java b/samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/Application.java similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/src/main/java/customer/bookshop/Application.java rename to samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/Application.java diff --git a/cds-feature-sap-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 similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java rename to samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/CatalogServiceHandler.java diff --git a/cds-feature-sap-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 similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java rename to samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionHandler.java diff --git a/cds-feature-sap-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 similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java rename to samples/document-ai-bookshop/srv/src/main/java/customer/bookshop/handlers/DocumentExtractionResultHandler.java diff --git a/cds-feature-sap-document-ai/bookshop/srv/src/main/resources/application.yaml b/samples/document-ai-bookshop/srv/src/main/resources/application.yaml similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/src/main/resources/application.yaml rename to samples/document-ai-bookshop/srv/src/main/resources/application.yaml diff --git a/cds-feature-sap-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 similarity index 100% rename from cds-feature-sap-document-ai/bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java rename to samples/document-ai-bookshop/srv/src/test/java/customer/bookshop/handlers/CatalogServiceHandlerTest.java From 53b652450c3b8d99612e0e0b90f62da5931f546c Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 13:53:01 +0200 Subject: [PATCH 62/70] chore(document-ai): remove duplicated and obsolete imported files - Delete the imported LICENSE (Apache 2.0 already present at repo root, only whitespace-different). - Delete the imported .github/ workflows and .gitignore; both are superseded by the repo-level equivalents in cds-ai. - Delete .hyperspace/pull_request_bot.json (byte-identical to the copy at repo root). - Delete the leftover cds-feature-sap-document-ai/integration-tests/ directory: its Java test sources were moved into integration-tests/spring in the previous commit; the remaining Application.java, application.yaml, logback-test.xml, pom.xml, package.json will be reintegrated into the spring integration-tests module in commit 5. --- .../.github/workflows/main.yml | 33 --- cds-feature-sap-document-ai/.gitignore | 36 ---- .../.hyperspace/pull_request_bot.json | 30 --- cds-feature-sap-document-ai/LICENSE | 201 ------------------ .../integration-tests/.gitignore | 1 - .../integration-tests/package.json | 12 -- .../integration-tests/pom.xml | 158 -------------- .../integrationtest/Application.java | 14 -- .../src/main/resources/application.yaml | 9 - .../src/test/resources/logback-test.xml | 15 -- 10 files changed, 509 deletions(-) delete mode 100644 cds-feature-sap-document-ai/.github/workflows/main.yml delete mode 100644 cds-feature-sap-document-ai/.gitignore delete mode 100644 cds-feature-sap-document-ai/.hyperspace/pull_request_bot.json delete mode 100644 cds-feature-sap-document-ai/LICENSE delete mode 100644 cds-feature-sap-document-ai/integration-tests/.gitignore delete mode 100644 cds-feature-sap-document-ai/integration-tests/package.json delete mode 100644 cds-feature-sap-document-ai/integration-tests/pom.xml delete mode 100644 cds-feature-sap-document-ai/integration-tests/src/main/java/com/sap/cds/feature/documentai/integrationtest/Application.java delete mode 100644 cds-feature-sap-document-ai/integration-tests/src/main/resources/application.yaml delete mode 100644 cds-feature-sap-document-ai/integration-tests/src/test/resources/logback-test.xml diff --git a/cds-feature-sap-document-ai/.github/workflows/main.yml b/cds-feature-sap-document-ai/.github/workflows/main.yml deleted file mode 100644 index b0ed368..0000000 --- a/cds-feature-sap-document-ai/.github/workflows/main.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Main - -on: - pull_request: - push: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install @sap/cds-dk - run: npm i -g @sap/cds-dk@9.9.1 - - - name: Set up Java - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: sapmachine - cache: maven - - - name: Build / run tests - working-directory: sap-document-ai - run: mvn clean verify diff --git a/cds-feature-sap-document-ai/.gitignore b/cds-feature-sap-document-ai/.gitignore deleted file mode 100644 index 980be25..0000000 --- a/cds-feature-sap-document-ai/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# Compiled class file -*.class - -# Log file -*.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* -replay_pid* - -# Maven build output -target/ -.flattened-pom.xml - -# CDS build output -**/gen/ -**/node_modules/ -**/package-lock.json - -# IDE -.idea/ \ No newline at end of file diff --git a/cds-feature-sap-document-ai/.hyperspace/pull_request_bot.json b/cds-feature-sap-document-ai/.hyperspace/pull_request_bot.json deleted file mode 100644 index af7508b..0000000 --- a/cds-feature-sap-document-ai/.hyperspace/pull_request_bot.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://devops-insights-pr-bot.cfapps.eu10-004.hana.ondemand.com/schema/pull_request_bot.json", - "features": { - "control_panel": false, - "summarize": { - "auto_generate_summary": true, - "auto_insert_summary": true, - "auto_run_on_draft_pr": true, - "use_custom_summarize_prompt": false, - "use_custom_summarize_output_template": false, - "excluded_paths": [], - "auto_exclude_authors": [] - }, - "review": { - "auto_generate_review": true, - "auto_run_on_draft_pr": false, - "use_custom_review_focus": false, - "excluded_paths": [], - "auto_exclude_authors": [] - }, - "sonar_fix": { - "enable": true, - "excluded_rules": [] - }, - "pipeline_fix": { - "enable": true - } - }, - "excluded_paths": [] -} diff --git a/cds-feature-sap-document-ai/LICENSE b/cds-feature-sap-document-ai/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- a/cds-feature-sap-document-ai/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/cds-feature-sap-document-ai/integration-tests/.gitignore b/cds-feature-sap-document-ai/integration-tests/.gitignore deleted file mode 100644 index a0c4db2..0000000 --- a/cds-feature-sap-document-ai/integration-tests/.gitignore +++ /dev/null @@ -1 +0,0 @@ -srv/ diff --git a/cds-feature-sap-document-ai/integration-tests/package.json b/cds-feature-sap-document-ai/integration-tests/package.json deleted file mode 100644 index d54874a..0000000 --- a/cds-feature-sap-document-ai/integration-tests/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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/cds-feature-sap-document-ai/integration-tests/pom.xml b/cds-feature-sap-document-ai/integration-tests/pom.xml deleted file mode 100644 index 15ed264..0000000 --- a/cds-feature-sap-document-ai/integration-tests/pom.xml +++ /dev/null @@ -1,158 +0,0 @@ - - - 4.0.0 - - com.sap.cds - sap-document-ai-integration-tests - 1.0-SNAPSHOT - jar - sap-document-ai-integration-tests - - - 17 - - 4.9.1 - 3.5.15 - UTF-8 - - - - - - com.sap.cds - cds-services-bom - ${cds.services.version} - pom - import - - - org.springframework.boot - spring-boot-dependencies - ${spring.boot.version} - pom - import - - - - - - - - com.sap.cds - sap-document-ai - 1.0-SNAPSHOT - - - - - com.sap.cds - cds-starter-spring-boot - - - - com.h2database - h2 - runtime - - - - - org.springframework.boot - spring-boot-starter-test - test - - - - - - - src/main/resources - - - srv/src/main/resources - - - - - maven-compiler-plugin - 3.14.1 - - ${jdk.version} - - - - - com.sap.cds - cds-maven-plugin - ${cds.services.version} - - - cds.install-node - - install-node - - - - cds.resolve - - resolve - - - - cds.build - - cds - - - - build --for java - deploy --to h2 --dry --out "${project.build.outputDirectory}/schema-h2.sql" - - - - - - - - - maven-surefire-plugin - 3.5.4 - - - **/*ITest.java - - - - - - - maven-failsafe-plugin - 3.5.4 - - - - integration-test - verify - - - - **/*ITest.java - - - - - - - - org.springframework.boot - spring-boot-maven-plugin - ${spring.boot.version} - - true - - - - - diff --git a/cds-feature-sap-document-ai/integration-tests/src/main/java/com/sap/cds/feature/documentai/integrationtest/Application.java b/cds-feature-sap-document-ai/integration-tests/src/main/java/com/sap/cds/feature/documentai/integrationtest/Application.java deleted file mode 100644 index 4bc8cb4..0000000 --- a/cds-feature-sap-document-ai/integration-tests/src/main/java/com/sap/cds/feature/documentai/integrationtest/Application.java +++ /dev/null @@ -1,14 +0,0 @@ -/* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ -package com.sap.cds.feature.documentai.integrationtest; - -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/cds-feature-sap-document-ai/integration-tests/src/main/resources/application.yaml b/cds-feature-sap-document-ai/integration-tests/src/main/resources/application.yaml deleted file mode 100644 index 0d53ee5..0000000 --- a/cds-feature-sap-document-ai/integration-tests/src/main/resources/application.yaml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - sql: - init: - platform: h2 - -cds: - data-source: - auto-config: - enabled: false diff --git a/cds-feature-sap-document-ai/integration-tests/src/test/resources/logback-test.xml b/cds-feature-sap-document-ai/integration-tests/src/test/resources/logback-test.xml deleted file mode 100644 index 366a1bf..0000000 --- a/cds-feature-sap-document-ai/integration-tests/src/test/resources/logback-test.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n - - - - - - - - - - - From e06f346e292ef6eb012615568e30cf21d4d93e8d Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 13:54:44 +0200 Subject: [PATCH 63/70] refactor(document-ai): adapt plugin pom to cds-ai conventions - Set parent to cds-ai-root, drop local version, groupId, dependencyManagement and spotless/pmd/spotbugs/compiler configuration (inherit from root). - Depend on cds-services-api, cds-services-utils, cds4j-core, cds-services-impl and connectivity-apache-httpclient5 (versions managed by root parent or the SAP Cloud SDK BOM). - Add hermetic CDS build: cds.install-node and cds.npm-ci executions plus a local package.json pinning @sap/cds-dk 9.9.1 (matches cds-feature-ai-core). - Keep the plugin's existing cds.build layout (src/main/resources/cds/ com.sap.cds/sap-document-ai) via a workingDirectory-based invocation of the cds-maven-plugin cds goal, mirroring cds-feature-ai-core. - Add cds.generate for basePackage com.sap.cds.feature.documentai.generated.cds4j. - Add a module spotbugs-exclusion-filter.xml so the spotbugs check inherited from the root pluginManagement can resolve its excludeFilterFile. - Wire module-level jacoco (prepare-agent, report) matching sibling modules. --- cds-feature-sap-document-ai/package.json | 9 + cds-feature-sap-document-ai/pom.xml | 214 +++++------------- .../resources/spotbugs-exclusion-filter.xml | 24 ++ 3 files changed, 92 insertions(+), 155 deletions(-) create mode 100644 cds-feature-sap-document-ai/package.json create mode 100644 cds-feature-sap-document-ai/src/main/resources/spotbugs-exclusion-filter.xml 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 index c8d2ca4..193503f 100644 --- a/cds-feature-sap-document-ai/pom.xml +++ b/cds-feature-sap-document-ai/pom.xml @@ -1,177 +1,132 @@ - + 4.0.0 - com.sap.cds - sap-document-ai - 1.0-SNAPSHOT - jar - sap-document-ai - - 17 - 4.9.1 - UTF-8 - com.sap.cds.feature.documentai.generated - + + com.sap.cds + cds-ai-root + ${revision} + - - - - com.sap.cds - cds-services-bom - ${cds.services.version} - pom - import - - - + 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 - provided + com.sap.cds - cds4j-core - ${cds.services.version} - provided + cds-services-utils + - org.springframework - spring-context - 6.2.6 + com.sap.cds + cds4j-core provided + com.sap.cloud.sdk.cloudplatform connectivity-apache-httpclient5 - 5.28.0 - provided - - - org.junit.jupiter - junit-jupiter - 5.11.0 - test + + - org.mockito - mockito-junit-jupiter - 5.12.0 + com.sap.cds + cds-services-impl test + org.awaitility awaitility 4.2.2 test - - org.assertj - assertj-core - 3.26.3 - test - - - com.sap.cds - cds-services-utils - provided - - - com.sap.cds - cds-services-impl - test - + ${project.artifactId} - - maven-compiler-plugin - 3.14.1 - - ${jdk.version} - - com.sap.cds cds-maven-plugin - ${cds.services.version} + + 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 - ${project.basedir}/src/main/resources/cds/com.sap.cds/sap-document-ai --dest - ${project.basedir}/gen/srv + build --for java --src ./ --dest ../../../../../../gen/srv - cds.generate generate - ${generation-package}.cds4j - ${project.basedir}/gen/srv/src/main/resources/edmx/csn.json + com.sap.cds.feature.documentai.generated.cds4j + ${project.basedir}/src/gen/srv/src/main/resources/edmx/csn.json sap.document.ai.** - + - maven-pmd-plugin - 3.28.0 + maven-clean-plugin - ${jdk.version} - 5 - ${project.build.directory} - true - true - false - false - - - /rulesets/java/maven-pmd-plugin-default.xml - - - **/feature/documentai/generated/** - + + + ./ + + .flattened-pom.xml + + + - - - - com.sap.cloud.sdk.quality - pmd-rules - 3.78.0 - - - pmd-error + auto-clean - check - cpd-check + clean - process-test-classes + clean + org.jacoco jacoco-maven-plugin - 0.8.12 **/feature/documentai/generated/** @@ -179,72 +134,21 @@ - jacoco-prepare + jacoco-initialize prepare-agent - jacoco-report + jacoco-site-report report verify - - jacoco-check - - check - - verify - - - - BUNDLE - - - INSTRUCTION - COVEREDRATIO - 0.85 - - - - - - - - - - com.diffplug.spotless - spotless-maven-plugin - 3.1.0 - - - - - - - /* -* © $YEAR SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ - - - - - pom.xml - - - - - - - - check - - process-sources - + 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..ee4e277 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/resources/spotbugs-exclusion-filter.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + From 28ac763c6ff517db6b16dae58f6a30d4739d1a32 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 13:55:44 +0200 Subject: [PATCH 64/70] refactor(document-ai): normalize license headers and plugin name references - Rewrite the copyright header in every imported Java file (plugin sources, moved bookshop handlers and moved integration tests): * update contributor line from "cds-feature-sap-document-ai contributors" to "cds-ai contributors" so files match the license header enforced by the root spotless configuration; * fix indentation from "* " / "*/" to " * " / " */" to match the canonical style used by the rest of the repository. - Update the DocumentExtractionHandler error message in the bookshop sample to reference the new artifactId cds-feature-sap-document-ai. --- .../configuration/DocumentAiServiceConfiguration.java | 4 ++-- .../documentai/handlers/DocumentSubmissionHandler.java | 4 ++-- .../feature/documentai/handlers/ExtractionPollingHandler.java | 4 ++-- .../service/DefaultDocumentAiProcessingService.java | 4 ++-- .../documentai/service/DocumentAiProcessingService.java | 4 ++-- .../sap/cds/feature/documentai/service/ExtractionService.java | 4 ++-- .../cds/feature/documentai/service/ExtractionServiceImpl.java | 4 ++-- .../sap/cds/feature/documentai/service/ExtractionStatus.java | 4 ++-- .../documentai/service/client/DefaultDocumentAiClient.java | 4 ++-- .../feature/documentai/service/client/DocumentAiClient.java | 4 ++-- .../service/exceptions/ConcurrentJobUpdateException.java | 4 ++-- .../documentai/service/exceptions/DocumentAiException.java | 4 ++-- .../service/exceptions/IllegalStatusTransitionException.java | 4 ++-- .../cds/feature/documentai/service/model/DocumentInput.java | 4 ++-- .../cds/feature/documentai/service/model/ExtractionData.java | 4 ++-- .../feature/documentai/service/model/ExtractionResult.java | 4 ++-- .../documentai/service/utils/StatusTransitionValidator.java | 4 ++-- .../configuration/DocumentAiServiceConfigurationTest.java | 4 ++-- .../documentai/handlers/DocumentSubmissionHandlerTest.java | 4 ++-- .../documentai/handlers/ExtractionPollingHandlerTest.java | 4 ++-- .../service/DefaultDocumentAiProcessingServiceTest.java | 4 ++-- .../feature/documentai/service/ExtractionServiceImplTest.java | 4 ++-- .../service/client/DefaultDocumentAiClientTest.java | 4 ++-- .../feature/documentai/service/exceptions/ExceptionsTest.java | 4 ++-- .../service/utils/StatusTransitionValidatorTest.java | 4 ++-- .../documentai/integrationtest/AbstractDocumentAiITest.java | 4 ++-- .../documentai/integrationtest/DocumentSubmissionITest.java | 4 ++-- .../documentai/integrationtest/EventEmissionITest.java | 4 ++-- .../documentai/integrationtest/ExtractionErrorITest.java | 4 ++-- .../documentai/integrationtest/ExtractionLifecycleITest.java | 4 ++-- .../integrationtest/ExtractionResultCaptureHandler.java | 4 ++-- .../feature/documentai/integrationtest/PluginLoadITest.java | 4 ++-- .../customer/bookshop/handlers/DocumentExtractionHandler.java | 2 +- .../bookshop/handlers/DocumentExtractionResultHandler.java | 4 ++-- 34 files changed, 67 insertions(+), 67 deletions(-) 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 index 3691fb3..1917294 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 08b943e..2e2ea40 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index dd87b19..0ba4a70 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index c1050cc..ec4a73f 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 18c0b18..3972446 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 095010d..4658717 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 4825a91..2216619 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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.*; 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 index bccf52d..cfd2322 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ package com.sap.cds.feature.documentai.service; /** 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 index 5fc99bc..21057fa 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 295699f..cd327c4 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 7dd26cb..d58ea7e 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ package com.sap.cds.feature.documentai.service.exceptions; /** 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 index 27f46f3..38333fc 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ package com.sap.cds.feature.documentai.service.exceptions; /** 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 index 4d3ba04..a23a5ca 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ package com.sap.cds.feature.documentai.service.exceptions; /** 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 index a89e467..603f508 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ package com.sap.cds.feature.documentai.service.model; import java.io.InputStream; 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 index ff584b3..0c81547 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ package com.sap.cds.feature.documentai.service.model; /** 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 index 4da18f6..6fd5197 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ package com.sap.cds.feature.documentai.service.model; /** 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 index 775a890..febe1fa 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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.*; 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 index eb34dca..fbfade8 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index cd7db2b..90be694 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 352e847..610dc8b 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 649a8d5..212a68c 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index d7912a3..3f05fad 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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.*; 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 index 5d1c319..9a2226c 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 36ec33f..61a818f 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index b54bce5..7e3ff9d 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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.*; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java index f8fc259..7fb2993 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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_; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java index 0c7f375..cb9a1e2 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java index ee0261e..93ffe81 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java index bc6041d..13e58d1 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java index f3ac113..bbd89e9 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 3aaa606..b9a4283 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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_; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java index 0766900..edf9a2a 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; 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 index 874204f..241ef79 100644 --- 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 @@ -101,7 +101,7 @@ public void onExtractDocumentData(BooksExtractDocumentDataContext context) { 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 sap-document-ai plugin is configured."); + "Document AI service is not available. Please ensure the cds-feature-sap-document-ai plugin is configured."); } DocumentExtraction event = DocumentExtraction.create(); 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 index 8cb2947..9561f93 100644 --- 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 @@ -1,6 +1,6 @@ /* -* © 2026 SAP SE or an SAP affiliate company and cds-feature-sap-document-ai contributors. -*/ + * © 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; From 9547d98c229dbd9ed3da398b5a93fd8833b00e93 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 13:57:37 +0200 Subject: [PATCH 65/70] chore(document-ai): wire plugin, integration tests and sample into reactor Root pom: - Register cds-feature-sap-document-ai as a Maven module. - Add a dependencyManagement entry for cds-feature-sap-document-ai using ${revision} so downstream modules pick up the CI-friendly version. Integration tests (integration-tests/spring): - Add cds-feature-sap-document-ai (managed) as a test-scope reachable dependency so tests can reach the plugin's generated cds4j classes and runtime handlers. - Add org.awaitility:awaitility 4.2.2 (test) used by the moved tests. - Add "using from 'com.sap.cds/sap-document-ai';" to test-service.cds so the CDS build for the integration-tests reactor includes the plugin model and the H2 deploy provisions the ExtractionJob table. - Rename moved *ITest.java files to *Test.java so surefire picks them up (the spring integration-tests module runs everything through surefire and does not have failsafe wired), and update internal class references. Samples (samples/document-ai-bookshop): - Swap plugin coordinates from sap-document-ai:1.0-SNAPSHOT to com.sap.cds:cds-feature-sap-document-ai using a version property. - Pin cds-feature-sap-document-ai.version to 0.0.1-alpha (matches root ${revision}) and align cds.services.version to the repo default 4.9.0. --- integration-tests/spring/pom.xml | 12 ++++++++++++ ...umentAiITest.java => AbstractDocumentAiTest.java} | 2 +- ...missionITest.java => DocumentSubmissionTest.java} | 2 +- ...ventEmissionITest.java => EventEmissionTest.java} | 2 +- ...ctionErrorITest.java => ExtractionErrorTest.java} | 2 +- ...ecycleITest.java => ExtractionLifecycleTest.java} | 2 +- .../{PluginLoadITest.java => PluginLoadTest.java} | 2 +- integration-tests/spring/test-service.cds | 1 + pom.xml | 7 +++++++ samples/document-ai-bookshop/pom.xml | 7 ++++--- samples/document-ai-bookshop/srv/pom.xml | 3 +-- 11 files changed, 31 insertions(+), 11 deletions(-) rename integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/{AbstractDocumentAiITest.java => AbstractDocumentAiTest.java} (98%) rename integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/{DocumentSubmissionITest.java => DocumentSubmissionTest.java} (98%) rename integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/{EventEmissionITest.java => EventEmissionTest.java} (96%) rename integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/{ExtractionErrorITest.java => ExtractionErrorTest.java} (95%) rename integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/{ExtractionLifecycleITest.java => ExtractionLifecycleTest.java} (98%) rename integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/{PluginLoadITest.java => PluginLoadTest.java} (93%) 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/AbstractDocumentAiITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java similarity index 98% rename from integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java index 7fb2993..35a5841 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java @@ -23,7 +23,7 @@ import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -abstract class AbstractDocumentAiITest { +abstract class AbstractDocumentAiTest { @Autowired ServiceCatalog serviceCatalog; @Autowired PersistenceService persistenceService; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java similarity index 98% rename from integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java index cb9a1e2..624ab28 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentSubmissionTest.java @@ -19,7 +19,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -class DocumentSubmissionITest extends AbstractDocumentAiITest { +class DocumentSubmissionTest extends AbstractDocumentAiTest { @Autowired ExtractionService extractionService; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java similarity index 96% rename from integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java index 93ffe81..d73fa4d 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/EventEmissionTest.java @@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -class EventEmissionITest extends AbstractDocumentAiITest { +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\"}"; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java similarity index 95% rename from integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java index 13e58d1..b291d00 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionErrorTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -class ExtractionErrorITest extends AbstractDocumentAiITest { +class ExtractionErrorTest extends AbstractDocumentAiTest { private static final String DIE_JOB_ID = "die-1"; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java similarity index 98% rename from integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java index bbd89e9..76c7eae 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/ExtractionLifecycleTest.java @@ -19,7 +19,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -class ExtractionLifecycleITest extends AbstractDocumentAiITest { +class ExtractionLifecycleTest extends AbstractDocumentAiTest { private static final String DIE_JOB_ID = "die-job-1"; private static final String EXTRACTION_RESULT_JSON = "{\"invoiceNumber\":\"INV-001\"}"; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java similarity index 93% rename from integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java rename to integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java index edf9a2a..5f861b6 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadITest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/PluginLoadTest.java @@ -11,7 +11,7 @@ import com.sap.cds.services.Service; import org.junit.jupiter.api.Test; -class PluginLoadITest extends AbstractDocumentAiITest { +class PluginLoadTest extends AbstractDocumentAiTest { @Test void documentAiServiceIsRegisteredInCatalog() { 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/pom.xml b/samples/document-ai-bookshop/pom.xml index d8b2f4a..38b520e 100644 --- a/samples/document-ai-bookshop/pom.xml +++ b/samples/document-ai-bookshop/pom.xml @@ -17,8 +17,9 @@ 17 - 4.9.1 + 4.9.0 3.5.8 + 0.0.1-alpha https://nodejs.org/dist/ UTF-8 @@ -41,8 +42,8 @@ com.sap.cds - sap-document-ai - 1.0-SNAPSHOT + cds-feature-sap-document-ai + ${cds-feature-sap-document-ai.version} diff --git a/samples/document-ai-bookshop/srv/pom.xml b/samples/document-ai-bookshop/srv/pom.xml index 31abea2..51a8279 100644 --- a/samples/document-ai-bookshop/srv/pom.xml +++ b/samples/document-ai-bookshop/srv/pom.xml @@ -51,8 +51,7 @@ com.sap.cds - sap-document-ai - 1.0-SNAPSHOT + cds-feature-sap-document-ai From c2ed8e2b5aac37f9b29430ada29fc0af50b69c05 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 14:00:18 +0200 Subject: [PATCH 66/70] fix(document-ai): resolve missing dependency version and CI-friendly build blockers - Set explicit version on cds4j-core dependency (com.sap.cds:cds4j-core is not managed by cds-services-bom); use ${cds.services.version} to keep it aligned with the rest of the CDS stack. - Commit package-lock.json so the cds-maven-plugin cds.npm-ci execution can run non-interactively (mirrors cds-feature-ai-core and cds-feature- recommendations). - Extend the module-local spotbugs-exclusion-filter.xml to also skip the DM_DEFAULT_ENCODING pattern for classes matching *Test / *TestBase. The imported unit tests use byte fixtures that intentionally rely on the platform default encoding; production code is not affected. --- cds-feature-sap-document-ai/package-lock.json | 1861 +++++++++++++++++ cds-feature-sap-document-ai/pom.xml | 1 + .../resources/spotbugs-exclusion-filter.xml | 2 + 3 files changed, 1864 insertions(+) create mode 100644 cds-feature-sap-document-ai/package-lock.json 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/pom.xml b/cds-feature-sap-document-ai/pom.xml index 193503f..484cf19 100644 --- a/cds-feature-sap-document-ai/pom.xml +++ b/cds-feature-sap-document-ai/pom.xml @@ -28,6 +28,7 @@ com.sap.cds cds4j-core + ${cds.services.version} provided 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 index ee4e277..95d797b 100644 --- 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 @@ -19,6 +19,8 @@ + + From bbfa50077607bc8583c8a4eb9e96c657cc1a7de5 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 14:06:14 +0200 Subject: [PATCH 67/70] style: apply spotless across the reactor Ran `mvn spotless:apply` at the repo root. Reformatting affects: - The moved doc-ai integration tests under integration-tests/spring (five files): minor line-break normalization introduced during the cds-feature-sap-document-ai import. - One pre-existing formatting nit in cds-feature-ai-core/.../MockAICoreApiHandler.java that the previous runs of spotless never caught because the spotless check phase is gated by `${spotless.check.skip}` (default true) in the root pom. No functional changes; unit and integration tests still pass. --- .../feature/aicore/core/handler/MockAICoreApiHandler.java | 7 +++++-- .../documentai/integrationtest/AbstractDocumentAiTest.java | 6 +++--- .../documentai/integrationtest/DocumentSubmissionTest.java | 6 +++--- .../documentai/integrationtest/EventEmissionTest.java | 7 ++----- .../documentai/integrationtest/ExtractionErrorTest.java | 3 +-- .../integrationtest/ExtractionLifecycleTest.java | 6 ++---- 6 files changed, 16 insertions(+), 19 deletions(-) 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/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 index 35a5841..36f2b7f 100644 --- 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 @@ -35,10 +35,10 @@ 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. + // Executes a single polling cycle using a test DIE client that returns results supplied by the + // caller. void runPollCycle( - ExtractionService extractionService, - Function jobResultFn) { + ExtractionService extractionService, Function jobResultFn) { ExtractionPollingHandler handler = new ExtractionPollingHandler( persistenceService, 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 index 624ab28..3c6eb8f 100644 --- 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 @@ -21,8 +21,7 @@ class DocumentSubmissionTest extends AbstractDocumentAiTest { - @Autowired - ExtractionService extractionService; + @Autowired ExtractionService extractionService; @Test void submissionWithoutDieBindingCreatesJobAsPending() { @@ -31,7 +30,8 @@ void submissionWithoutDieBindingCreatesJobAsPending() { documentAiService.emit(createExtractionContext()); - assertThat(persistenceService.run(Select.from(ExtractionJob_.class)).listOf(ExtractionJob.class)) + assertThat( + persistenceService.run(Select.from(ExtractionJob_.class)).listOf(ExtractionJob.class)) .singleElement() .satisfies( job -> { 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 index d73fa4d..ab46f87 100644 --- 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 @@ -8,7 +8,6 @@ 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 org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -18,10 +17,8 @@ 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; + @Autowired ExtractionService extractionService; + @Autowired ExtractionResultCaptureHandler captureHandler; @AfterEach void resetCapture() { 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 index b291d00..6ca8d7c 100644 --- 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 @@ -15,8 +15,7 @@ class ExtractionErrorTest extends AbstractDocumentAiTest { private static final String DIE_JOB_ID = "die-1"; - @Autowired - ExtractionService extractionService; + @Autowired ExtractionService extractionService; @Test void invalidStatusTransitionThrows() { 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 index 76c7eae..8cc0017 100644 --- 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 @@ -24,10 +24,8 @@ 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; + @Autowired ExtractionService extractionService; + @Autowired ExtractionResultCaptureHandler captureHandler; @AfterEach void resetCapture() { From b4913cde5373e85848be9e870f0835a5e992379b Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 14:29:34 +0200 Subject: [PATCH 68/70] ci: preinstall cds-feature-sap-document-ai for integration tests The integration-tests/spring module now compile-depends on com.sap.cds:cds-feature-sap-document-ai:${revision}. The composite action for the Integration Tests job first installs a small subset of plugins to ~/.m2 with a -pl list and then runs `mvn clean verify` against integration-tests/pom.xml. The new plugin was missing from that list, so dependency resolution failed with: Could not find artifact com.sap.cds:cds-feature-sap-document-ai: jar:0.0.1-alpha in central Add cds-feature-sap-document-ai to the -pl list so the plugin gets built and installed locally before the integration-tests reactor runs. -am still resolves the transitive dependency graph correctly. --- .github/actions/integration-tests/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From a31d322798bd9f8d0433c317f026effd517acd1a Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 14:45:52 +0200 Subject: [PATCH 69/70] fix(mtx-local): pin @sap/cds ^9 at workspace root to prevent duplicate cds install Reproducing the failing CI 'Local MTX Tests' job locally showed that a fresh 'npm install' in integration-tests/mtx-local/ ends up with three copies of @sap/cds: - mtx-local/node_modules/@sap/cds @ 10.0.3 (hoisted) - mtx-local/node_modules/@sap/cds-dk/node_modules/@sap/cds @ 9.9.1 - mtx-local/mtx/sidecar/node_modules/@sap/cds @ 9.9.2 The sidecar boots with 'cds-serve --profile development' and refuses to start with: ERROR: @sap/cds was loaded from different locations Root cause: * @sap/cds 10.0.3 was published to npm (major bump from 9.x). * @sap/cds-mtxs 3.9.5 (latest 3.x) declares peer '@sap/cds: >=9' - the loose lower bound. With no top-level pin, npm satisfies that peer with the newest matching version and hoists 10.0.3. * The sidecar itself declares '@sap/cds: ^9' (strict 9.x) so it keeps a nested 9.x copy. Same for @sap/cds-dk, whose own dep range '^8.3 || ^9' can not accept 10.x. Result: three copies of @sap/cds, cds-serve aborts. Fix: declare '@sap/cds: ^9' at the workspace root as a devDependency. This gives npm a top-level constraint that pins the hoisted @sap/cds to 9.x, which simultaneously satisfies cds-mtxs's '>=9', cds-dk's '^8.3 || ^9' and the sidecar's '^9' - all resolve to the same hoisted 9.x copy. Verified locally with a full 'mvn clean verify -pl integration-tests/mtx-local/srv -am -P mtx-integration-tests' run: 8 MTX tests pass, sidecar starts cleanly. The 'CI - PR' job for main was not affected earlier only because @sap/ cds 10 was published between PR 95's run (2026-07-02) and PR 96's run (2026-07-03). --- integration-tests/mtx-local/package.json | 1 + 1 file changed, 1 insertion(+) 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" }, From e270483301d8ebc847b312f488dc2917cd26934a Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Fri, 3 Jul 2026 14:55:10 +0200 Subject: [PATCH 70/70] adapt readme --- cds-feature-sap-document-ai/README.md | 115 +++++++++++++------------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/cds-feature-sap-document-ai/README.md b/cds-feature-sap-document-ai/README.md index a33c068..926b79c 100644 --- a/cds-feature-sap-document-ai/README.md +++ b/cds-feature-sap-document-ai/README.md @@ -1,6 +1,6 @@ # 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. +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 @@ -39,13 +39,13 @@ For a working reference, see the [Bookshop Sample](#bookshop-sample), which demo ## 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`) | +| 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. @@ -56,7 +56,7 @@ All plugin dependencies are declared with `provided` scope and are available on This section walks through integrating the plugin into an existing CAP Java application from start to finish. -### Step 1 — Add the dependency +### Step 1 - Add the dependency Declare the plugin in `srv/pom.xml`: @@ -86,7 +86,7 @@ Ensure the `cds-maven-plugin` is configured with the `resolve` goal so the plugi ``` -### Step 2 — Enable the persistent outbox +### Step 2 - Enable the persistent outbox Add the following to `src/main/resources/application.yaml`: @@ -100,7 +100,7 @@ cds: Without this, documents will be submitted to DIE but results will never be retrieved. -### Step 3 — Bind the DIE service +### 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. @@ -111,15 +111,15 @@ 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: +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. +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 +### Step 4 - Emit a DocumentExtraction event From any event handler or service method in your application, emit a `DocumentExtraction` event: @@ -140,7 +140,7 @@ myApplicationService.emit(ctx); The call returns immediately. The plugin handles submission and schedules polling asynchronously. -### Step 5 — Handle the result +### 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: @@ -165,14 +165,14 @@ public class MyExtractionResultHandler implements EventHandler { } ``` -### Step 6 — Build and run +### 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. +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. --- @@ -181,35 +181,35 @@ Submit a document via your application. The plugin logs progress at `INFO` level > **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 | +| 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` | +| 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. +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 | +| 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 | --- @@ -236,13 +236,13 @@ To run the sample with a real DIE service instance, the SAP BTP Cloud Foundry en **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:** +**Step 1 - Log in to Cloud Foundry:** ```bash cf login ``` -**Step 2 — Bind the DIE service instance:** +**Step 2 - Bind the DIE service instance:** ```bash cd bookshop @@ -251,17 +251,17 @@ 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:** +**Step 3 - Compile and run with the hybrid profile:** ```bash cd bookshop mvn compile -cds bind --exec mvn spring-boot:run +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. +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. --- @@ -299,7 +299,7 @@ The poll interval can be configured in `application.yaml`: cds: document-ai: polling: - interval-seconds: 3 # default + interval-seconds: 3 # default ``` The outbox retry limit can be adjusted alongside other outbox services: @@ -316,13 +316,13 @@ cds: 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) | +| 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) | --- @@ -336,9 +336,9 @@ For a detailed description of the plugin's design, component responsibilities, e 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 | +| 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. @@ -348,11 +348,11 @@ The plugin communicates with the SAP Document Information Extraction service via 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 | +| 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`: @@ -362,7 +362,9 @@ logging: level: com.sap.cds.feature.documentai: DEBUG ``` + --- + ## References - [Getting Started with CAP](https://cap.cloud.sap/docs/get-started/) @@ -372,6 +374,7 @@ logging: - [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 @@ -385,11 +388,11 @@ logging: 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 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 +- 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