From 62308445d1ed964f99cee222f0feba087d96c11d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:38:58 +0000 Subject: [PATCH] Add two samples that reach an SAP system: OData and RFC cap2UI5/samples-stack was empty. It starts with the two integrations an SAP developer asks for first, both built the way CAP builds them, so the samples show that a cap2UI5 app needs nothing of its own to reach another system - it is a CAP handler, and CAP's remote services do it: - Z2UI5_CL_CAPS_APP_001 reads business partners from S/4HANA's OData service API_BUSINESS_PARTNER with cds.ql, which CAP's remote service sends as OData V2 ($select, $filter, $top, $inlinecount). - Z2UI5_CL_CAPS_APP_002 calls BAPI_USER_GETLIST and BAPI_USER_GET_DETAIL over RFC with @sap/cds-rfc: single values, structures and tables in both directions, errors read from RETURN. node-rfc, which the classic Node.js examples use, is archived by SAP; @sap/cds-rfc is its place in a CAP project. Both run as they are: cds watch mocks the two systems while no credentials are configured (CSV rows for the OData service, srv/external/SAP_RFC.js for the BAPIs), and credentials alone switch them to the real systems - the README says how, for the Business Accelerator Hub sandbox, an own system and SAP BTP. SAP_RFC needs "external": true in cds.requires for that: the rfc kind does not declare itself external, and without the flag CAP keeps the mock next to the model even with credentials configured. The tests drive both samples over the wire twice: against the mocks, and with credentials against faked far ends - an OData V2 server and a stand-in for the RFC connector below @sap/cds-rfc - asserting what the systems would receive. Every view the samples display passes the abap2UI5 linter's property gate at UI5 1.71. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LHXmTxE1Si2KM9RpCZBUbG --- .gitattributes | 10 + .github/workflows/test.yml | 28 ++ .gitignore | 11 + .nvmrc | 1 + AGENTS.md | 166 ++++++++ CLAUDE.md | 7 + LICENSE | 21 + README.md | 313 ++++++++++++++- eslint.config.js | 36 ++ package.json | 80 ++++ srv/apps/z2ui5_cl_caps_app_001.js | 285 ++++++++++++++ srv/apps/z2ui5_cl_caps_app_002.js | 362 ++++++++++++++++++ srv/external/API_BUSINESS_PARTNER.cds | 40 ++ srv/external/SAP_RFC.cds | 150 ++++++++ srv/external/SAP_RFC.js | 140 +++++++ ...API_BUSINESS_PARTNER-A_BusinessPartner.csv | 15 + ...INESS_PARTNER-A_BusinessPartnerAddress.csv | 15 + test/fixtures/node-rfc-library.cjs | 49 +++ test/remote.test.mjs | 130 +++++++ test/samples.test.mjs | 112 ++++++ test/server.mjs | 86 +++++ 21 files changed, 2056 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 srv/apps/z2ui5_cl_caps_app_001.js create mode 100644 srv/apps/z2ui5_cl_caps_app_002.js create mode 100644 srv/external/API_BUSINESS_PARTNER.cds create mode 100644 srv/external/SAP_RFC.cds create mode 100644 srv/external/SAP_RFC.js create mode 100644 srv/external/data/API_BUSINESS_PARTNER-A_BusinessPartner.csv create mode 100644 srv/external/data/API_BUSINESS_PARTNER-A_BusinessPartnerAddress.csv create mode 100644 test/fixtures/node-rfc-library.cjs create mode 100644 test/remote.test.mjs create mode 100644 test/samples.test.mjs create mode 100644 test/server.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..86add80 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Normalize line endings to LF for all text files. +* text=auto eol=lf +*.cds text eol=lf +*.csv text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bf6513a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + # No lockfile, as in cap2UI5 and cap2UI5/samples: the run tests what an + # install gets today. @sap/cds-rfc's optional connector + # (@sap-rfc/node-rfc-library) is not on npmjs.com and is skipped - the + # tests need none: samples.test.mjs runs against the mocks, + # remote.test.mjs against a stand-in for the connector. + - run: npm install --no-audit --no-fund + - run: npm run lint + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e02ab7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +package-lock.json +*.sqlite +*.sqlite-* +*.log +.DS_Store + +# credentials of the remote systems (README: "Connect ...") - never committed +.env +.cdsrc-private.json +default-env.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a5ada9f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,166 @@ +# AGENTS.md — cap2UI5/samples-stack + +The guide for this repository. Read it before changing anything. + +## 1. What this repository is, and is not + +cap2UI5 samples that need something **beyond a cap2UI5 installation**: a +remote system — an OData service, an SAP system reachable over RFC. It is +cap2UI5's counterpart of [abap2UI5/samples-stack](https://github.com/abap2UI5/samples-stack), +and the line is the same one: a sample that runs on cap2UI5 alone belongs in +[cap2UI5/samples](https://github.com/cap2UI5/samples), not here. + +It is a **runnable CAP project, not an npm package**, unlike cap2UI5/samples. +A sample here is more than its app module: it needs its system's model in +`srv/external/` and its entry in `cds.requires`, and a package the plugin +loads apps from brings neither along. + +What a sample shows is that calling another system from a cap2UI5 app is +**CAP's job**: an app is a CAP handler, and it reaches a system through CAP's +remote services — `cds.connect.to`, `cds.ql`, `cds.requires`. So a sample uses +CAP's own means and nothing of its own around them: no wrapper, no client +library of its own, no connection code in the app. + +## 2. Layout + +``` +srv/apps/ one sample, one file. The plugin loads EVERY .js here as an app + module, so nothing else goes here - no shared helpers +srv/external/ the remote systems: + .cds the model - in the shape `cds import` writes (§5) + .js its mock, where rows are not enough (an RFC service) + data/-*.csv its mock's rows (an OData service) +test/ + server.mjs the project served in-process, and the abap2UI5 wire + samples.test.mjs every sample against the mocks + remote.test.mjs every sample through the real protocols, far ends faked + fixtures/ the faked far ends that are not HTTP servers +package.json cds.requires - one entry per remote system +``` + +## 3. Naming + +A sample is `Z2UI5_CL_CAPS_APP_`, in `srv/apps/z2ui5_cl_caps_app_.js`, +numbered in this repository. **`CAPS`, not `SMPS`**: numbers are handed out per +repository and the prefix is what qualifies them (abap2UI5/samples-stack's +rule), and the samples here have no ABAP original — they call CAP's remote +services, which ABAP does not have. `Z2UI5_CL_SMPS_APP_` stays reserved +for a translation of an abap2UI5/samples-stack sample, which would keep its +original's name, as cap2UI5/samples keeps abap2UI5/samples'. Name a sample by +its class in prose, never by its number alone. + +A remote service is named for what it is: an API by its own name, as `cds +import` names it (`API_BUSINESS_PARTNER`), a connection by what it reaches +(`SAP_RFC`). The name is the `cds.requires` key, the CDS service and the +production destination at once. + +## 4. The rule every sample follows: mocked AND remote + +- **It runs without the system.** `cds watch` (`--with-mocks`) mocks every + remote service that has no credentials, and the sample works against the + mock as it is — somebody without the system can still try it. +- **It runs against the system with credentials alone.** A `.env` file or a + destination, and not a line of the sample changes. That is the point of the + whole repository; a sample that needs an `if (mocked)` has missed it. +- **The mock behaves like the system where the sample depends on it.** The + RFC mock reports a missing user in `RETURN` and sends dates as `YYYYMMDD`, + because the ABAP system does; a mock that threw instead would teach the + wrong error handling. +- **It is tested both ways** (§6): `samples.test.mjs` against the mock, + `remote.test.mjs` through the protocol a real system is reached by, with + only the far end faked — the assertions are about what the system would + receive. +- **The screen says who answered** — the mock, a URL, a destination, a host — + and never shows a credential. +- **No credential in the repository.** `.env`, `.cdsrc-private.json` and + `default-env.json` are git-ignored; the README shows the keys, never values. + +## 5. The model of a remote system + +- **The shape `cds import` writes**, trimmed to what the samples use, with a + header comment that says so and how to import the whole interface. A reader + who imports the real thing must get a file that looks like the one here. +- **Names and types are the system's.** A property, a parameter, a DDIC field + that the system does not have is a sample that fails against it while every + test here is green. Take them from the service's metadata or the function + module's interface, never from memory alone. +- **An RFC service needs `"external": true` in `cds.requires`.** CAP's service + factory calls the kind's implementation (`@sap/cds-rfc`) only for a service + it knows as external, and the `rfc` kind does not say it is one - without + the flag CAP prefers `.js` next to the model, the mock, even with + credentials configured. `remote.test.mjs` fails if the flag goes. +- **An RFC service keeps `@protocol: 'rfc'`** (the importer writes it). CAP + knows no such protocol, so it serves the mock without an HTTP endpoint and + logs `ignoring unknown protocol: rfc` - which is the point: without the + annotation the mocked BAPIs would be callable over OData by anybody who can + reach the server. + +## 6. Build and verify + +```sh +npm install +npm run lint # eslint: no-undef and no-unused-vars, nothing else +npm test # node --test test/*.test.mjs +``` + +CI (`.github/workflows/test.yml`) runs exactly these, on the Node.js version of +`.nvmrc`, without a lockfile - as cap2UI5 and cap2UI5/samples do, the run tests +what an install gets today. + +- The tests play the frontend's part, one POST per roundtrip (`test/server.mjs`, + the same wire as cap2UI5/samples). `serve( )` passes `--with-mocks`: a + service with credentials is called, one without is mocked. +- **Every view and popup a sample displays goes through the abap2UI5 linter's + property gate** (`ui5Findings( )`, `@abap2ui5/linter`): every control, + property, aggregation and event against the UI5 metadata at 1.71, the + release the abap2UI5 family supports. A property that does not exist is not + an error in the browser - it renders as nothing - so this is the one place + it shows. The linter's render gate needs a browser and stays out of + `npm test`; run it by hand on the XML when a view changes shape. +- `remote.test.mjs` gives both services credentials before the server starts, + answers OData from an HTTP server in the test process, and points + `@sap-rfc/node-rfc-library` - the connector `@sap/cds-rfc` loads - at + `test/fixtures/node-rfc-library.cjs` with `module.registerHooks` + (Node.js ≥ 22.15). The stand-in implements what `@sap/cds-rfc` uses of the + connector and nothing more; if a new `@sap/cds-rfc` needs more, the test + says so. +- **Do not call `cds.test.log( )` in a test that serves `SAP_RFC` mocked.** CAP + skips an unknown protocol by returning what the logger's `warn( )` returns - + nothing, until the log is captured; captured, the warning itself is kept as + an endpoint and the server does not start ("Cannot find impl for protocol + adapter: undefined"). + +## 7. A sample's file + +- **The header** is the cap2UI5/samples one: `// @keywords` (what somebody + would type who does not know the sample exists), `// @summary` (one + sentence), `// @docs` where a documentation page exists — then prose: what + the sample demonstrates, and where the system comes from. +- **The view** is built with `z2ui5_cl_ui5_view_builder` in the house chain + layout — one call per line, four spaces per tree level, `end( )` in the + column of the `ele( )` it closes — as the translated samples of + cap2UI5/samples write it. Page titles read `cap2UI5 - - `. +- **Every remote call is caught** and ends in a message box that says what + happened; uncaught, the roundtrip answers `roundtrip failed` and the user + learns nothing. A BAPI's `RETURN` is checked after every call — a BAPI + reports there and does not raise. +- **The call is part of the roundtrip**, so the user waits for it: read once in + `check_on_init( )`, again only on an event, never in the render branch. +- **Anything the frontend sends is input**: event arguments and bound values + go into `cds.ql` as parameters and into RFC as values, never into query text. + +## 8. When you add a sample + +1. Check that it needs a remote system. If it does not, it belongs in + cap2UI5/samples. +2. Name it `Z2UI5_CL_CAPS_APP_` (§3). +3. Its system: the model in `srv/external/` (§5), the `cds.requires` entry, + the mock — rows in `srv/external/data/`, or `.js` where rows are + not enough. +4. Tests in both files (§6): what the sample shows, against the mock; what + the system receives, in `remote.test.mjs`. +5. The README: a row in *Which sample do I need?*, and a section with how it + works, how to connect the real system, and what is worth knowing. +6. `npm run lint && npm test`. + +Commit messages say why. The history of this project is its evidence. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a07a58b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +All project guidance lives in **[AGENTS.md](AGENTS.md)** — the single source of +truth for this repository (what belongs here, the naming, the mock-and-remote +rule every sample follows, the tests and how to add a sample). + +Read `AGENTS.md` before making any change. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d75b5d7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cap2UI5 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 01af46c..d114cc4 100644 --- a/README.md +++ b/README.md @@ -1 +1,312 @@ -# samples-stack \ No newline at end of file +# cap2UI5 — samples-stack + +**Learn how cap2UI5 plays with the rest of your stack: an app that reads an +OData service, and an app that calls a function module in an SAP system.** + +A [cap2UI5](https://github.com/cap2UI5/cap2UI5) app is a CAP handler, so it +reaches another system the way every CAP handler does — CAP's remote services: +the system's interface is imported as a CDS model, declared in `cds.requires`, +and the app `cds.connect.to`s it by name. Nothing in these samples is +cap2UI5's own; what they show is that none of it has to be. + +Every sample here needs something **beyond a cap2UI5 installation** — an OData +service, an SAP system reachable over RFC. And every sample **runs without +it**: `cds watch` mocks the system while no credentials are configured, and +once they are, the same app talks to the real one — not a line of it changes. +The top of each sample's screen says which of the two answered. + +## Which sample do I need? + +| You want to … | Sample | To connect a real system you need | +|---|---|---| +| Read an OData service — business partners from SAP S/4HANA, with `cds.ql` | [`Z2UI5_CL_CAPS_APP_001`](srv/apps/z2ui5_cl_caps_app_001.js) — [OData](#01--odata-read-an-odata-service) | `API_BUSINESS_PARTNER` of an S/4HANA system — or its sandbox on the SAP Business Accelerator Hub and an API key | +| Call a function module — a BAPI — in an SAP system over RFC | [`Z2UI5_CL_CAPS_APP_002`](srv/apps/z2ui5_cl_caps_app_002.js) — [RFC](#02--rfc-call-a-function-module-in-an-sap-system) | an ABAP system reachable over RFC, a user allowed to read users, and an RFC connector | + +## Run it + +```bash +npm install +cds watch # needs @sap/cds-dk (npm i -g @sap/cds-dk) - or: npm run mocked +``` + +`cds watch` prints the address of every sample. Log in as `alice` with an +empty password (CAP's mocked development user), then open + or +. + +The log shows what is mocked: + +``` +[cds] - mocking API_BUSINESS_PARTNER { at: [ '/odata/v4/api-business-partner' ], ... } +[adapters] - ignoring unknown protocol: rfc +[cds] - mocking SAP_RFC { ..., impl: 'srv/external/SAP_RFC.js' } +``` + +(The `rfc` line is CAP noticing that an RFC service has no HTTP endpoint: the +mock of the SAP system answers the apps, not the browser. The mock of the +OData service does both — its rows are at +.) + +``` +srv/ + apps/ the samples - one app, one file + external/ + API_BUSINESS_PARTNER.cds the OData service's model ─┐ what cds import + SAP_RFC.cds the SAP system's function modules ─┘ makes of a system + SAP_RFC.js the SAP system, mocked + data/ the OData service, mocked - its rows +package.json cds.requires: API_BUSINESS_PARTNER, SAP_RFC +``` + +## 01 — OData: read an OData service + +[`Z2UI5_CL_CAPS_APP_001`](srv/apps/z2ui5_cl_caps_app_001.js) lists business +partners of SAP S/4HANA's OData service `API_BUSINESS_PARTNER`, filters them by +name and category, and shows a partner's addresses on a row press. + +The app writes a query as it would against an entity of its own database: + +```js +const bupa = await cds.connect.to("API_BUSINESS_PARTNER"); +const { A_BusinessPartner } = bupa.entities; +const partners = await bupa.run(SELECT.from(A_BusinessPartner) + .columns("BusinessPartner", "BusinessPartnerFullName", ...) + .where`contains(BusinessPartnerFullName, ${this.search})` + .orderBy("BusinessPartner") + .limit(20)); +``` + +and CAP's remote service sends it as OData V2: + +``` +GET /sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner + ?$select=BusinessPartner,BusinessPartnerFullName,...&$orderby=BusinessPartner&$top=20 + &$filter=substringof('Cust',BusinessPartnerFullName)&$inlinecount=allpages +``` + +Three pieces make that work, all of them CAP's: + +1. **The model** — [`srv/external/API_BUSINESS_PARTNER.cds`](srv/external/API_BUSINESS_PARTNER.cds), + what `cds import` writes from the service's `$metadata`, trimmed to what + the sample reads. For the whole service: download the EDMX from the + [SAP Business Accelerator Hub](https://api.sap.com/api/API_BUSINESS_PARTNER/overview) + and `cds import API_BUSINESS_PARTNER.edmx --as cds --force`. +2. **The declaration** — `cds.requires.API_BUSINESS_PARTNER` in + [`package.json`](package.json): `kind: "odata-v2"` and the model. +3. **The call** — `cds.connect.to("API_BUSINESS_PARTNER")` and `cds.ql`. + +### Connect the real service + +Credentials make the difference: with them, CAP stops mocking the service and +calls it. Put them in a `.env` file in the project root — it is git-ignored — +and restart `cds watch`. + +**No S/4HANA system at hand? The sandbox.** The SAP Business Accelerator Hub +runs `API_BUSINESS_PARTNER` as a sandbox for everybody with an SAP account: log +on to [api.sap.com](https://api.sap.com), copy your API key, and + +```properties +cds.requires.API_BUSINESS_PARTNER.credentials.url=https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNER +cds.requires.API_BUSINESS_PARTNER.credentials.headers.APIKey= +``` + +**Your own SAP S/4HANA system**, with a user that may read business partners: + +```properties +cds.requires.API_BUSINESS_PARTNER.credentials.url=https://:/sap/opu/odata/sap/API_BUSINESS_PARTNER +cds.requires.API_BUSINESS_PARTNER.credentials.username= +cds.requires.API_BUSINESS_PARTNER.credentials.password= +``` + +The service has to be active in the system (`/IWFND/MAINT_SERVICE`). + +**On SAP BTP**, the `[production]` profile in `package.json` points the service +at the destination `S4HANA`: create it in the BTP cockpit and bind the app to +the Destination service (and the Connectivity service, if the system is +on-premise, behind the Cloud Connector). + +### Worth knowing + +- **The comparison is the service's.** `contains( )` becomes `substringof( )`, + and SAP Gateway compares case-sensitively; the mock — SQLite — does not. +- **A remote call can fail where the own database does not.** The sample + catches it and says what happened in a message box — an uncaught error would + answer the roundtrip with `roundtrip failed` and nothing else. +- **The call happens inside the roundtrip**, so the user waits for it: the + sample reads the first page when it starts and then only on an event, and + keeps `$top` small. +- **The row fields are the OData property names**, and the view binds them + uppercased: `BusinessPartnerFullName` is `{BUSINESSPARTNERFULLNAME}`. +- **Every user of the app reads with the credentials of `.env`.** For more than + a demo, restrict the app with `cds.requires.cap2ui5.roles`, or use a + destination with principal propagation, so that S/4HANA checks the actual + user. + +## 02 — RFC: call a function module in an SAP system + +[`Z2UI5_CL_CAPS_APP_002`](srv/apps/z2ui5_cl_caps_app_002.js) calls two BAPIs +that every ABAP system has: `BAPI_USER_GETLIST` for a list of users — a pattern +like `D*` goes in as a ranges table — and, on a row press, +`BAPI_USER_GET_DETAIL` for one user: address, logon data, lock status, roles. +Between them they pass what a function module can take and give — single +values, structures and tables, in both directions — and read the errors from +`RETURN`, where a BAPI reports them. + +### From node-rfc to `@sap/cds-rfc` + +The classic way to call a BAPI from Node.js is +[node-rfc](https://github.com/SAP-archive/node-rfc), as in Martin Maruskin's +[How to call BAPI in SAP from nodejs app](https://blog.maruskin.eu/2018/04/how-to-call-bapi-in-sap-from-nodejs-app.html) +— in node-rfc's last form: + +```js +const client = new Client({ ashost, sysnr, client, user, passwd }); +await client.open(); +const result = await client.call("BAPI_USER_GET_DETAIL", { USERNAME: "DEVELOPER" }); +``` + +SAP has since archived node-rfc (the npm package is deprecated, the repository +read-only — [SAP-archive/node-rfc#329](https://github.com/SAP-archive/node-rfc/issues/329)). +Its place in a CAP project is [`@sap/cds-rfc`](https://www.npmjs.com/package/@sap/cds-rfc), +SAP's RFC plugin for CAP: a function module becomes an **action of a remote +service**, and the call reads almost as before — + +```js +const sap = await cds.connect.to("SAP_RFC"); +const { ADDRESS, ACTIVITYGROUPS, RETURN } = await sap.BAPI_USER_GET_DETAIL({ USERNAME: "DEVELOPER" }); +``` + +— while the connection moved out of the code, into CAP's configuration: a +`.env` file on your machine, an RFC destination on SAP BTP, and a mock while you +develop. The parameters keep their ABAP names; a single value is a value, a +structure an object, a table an array of objects. + +The pieces: + +1. **The model** — [`srv/external/SAP_RFC.cds`](srv/external/SAP_RFC.cds), in + the shape `cds import --from rfc` writes: each function module an action, + its import and table parameters the action's parameters (annotated with + their kind), its exports and tables the result type. Trimmed to what the + sample uses; for a function module's whole interface, import it from your + system (connection as below): + + ```bash + cds import --from rfc --as cds --name BAPI_USER_GET_DETAIL --destination SAP_RFC --force + ``` + + `@sap/cds-rfc` passes exactly the parameters the model declares — one it + does not declare never reaches the system. +2. **The declaration** — `cds.requires.SAP_RFC` in [`package.json`](package.json): + `kind: "rfc"`, the model, and `"external": true`. The rfc kind does not + declare itself external, and without the flag CAP would keep choosing the + mock next to the model over `@sap/cds-rfc` — whatever the credentials say. +3. **The mock** — [`srv/external/SAP_RFC.js`](srv/external/SAP_RFC.js): what + CAP serves as `SAP_RFC` while no credentials are configured. It answers the + two BAPIs as an ABAP system does — dates as `YYYYMMDD`, an unknown user as + an `E` message in `RETURN`, not as an exception. +4. **The call** — `cds.connect.to("SAP_RFC")` and the BAPI as a method. + +### Connect the real system + +**1. Credentials**, in a `.env` file in the project root — it is git-ignored: + +```properties +cds.requires.SAP_RFC.credentials.ashost= +cds.requires.SAP_RFC.credentials.sysnr=00 +cds.requires.SAP_RFC.credentials.client=100 +cds.requires.SAP_RFC.credentials.user= +cds.requires.SAP_RFC.credentials.passwd= +``` + +RFC goes to the system's gateway, port `33` — `3300` for system +number `00`. + +**2. A connector.** `@sap/cds-rfc` speaks RFC through a connector library, +which is not on npmjs.com — without one, the sample says +`Calling SAP_RFC failed: Cannot find module '@sap-rfc/node-rfc-library'`. +There are two: + +- **SAP's `@sap-rfc/node-rfc-library`** — for SAP customers with an *SAP Build + Code* license, from SAP's own npm registry (the + [Repository Based Shipment Channel](https://help.sap.com/docs/RBSC/0a64be17478d4f5ba45d14ab62b0d74c/175673b12feb41739df4f041db52fe76.html)), + Linux and Windows only. Configure the registry for the `@sap-rfc` scope in + `.npmrc` as the [`@sap/cds-rfc` README](https://www.npmjs.com/package/@sap/cds-rfc) + shows, then `npm install`: it is an optional dependency of `@sap/cds-rfc` and + comes along. This is also the one for SAP BTP through the Cloud Connector. +- **[open-rfc](https://github.com/marianfoo/open-rfc)** — a community connector + without the SAP NW RFC SDK, written in plain JavaScript and published on + npmjs.com, which replaces SAP's below an unchanged `@sap/cds-rfc`. It is a + **beta** (0.x), and its beta covers direct connections to an application + server with user and password, on S/4HANA 2023 and NetWeaver 7.50. Add to + `package.json` + + ```json + "overrides": { + "@sap/cds-rfc": { + "@sap-rfc/node-rfc-library": "npm:open-rfc@0.2.4" + } + } + ``` + + and `npm install`. + +**3. Authorizations** for the RFC user: `S_RFC` for the function group +`SU_USER` (the two BAPIs), and `S_USER_GRP` with activity `03` for the user +groups it may read. + +**On SAP BTP**, the `[production]` profile in `package.json` points `SAP_RFC` at +the destination `SAP_RFC`: an RFC destination, through the Cloud Connector for +an on-premise system, with the app bound to the Destination and Connectivity +services. + +### Your own function module + +Import it — `cds import --from rfc --as cds --name Z_MY_FUNCTION --destination SAP_RFC` +adds it to `srv/external/SAP_RFC.cds` — and call it: +`await sap.Z_MY_FUNCTION({ ... })`. A name with a namespace, `/ABC/MY_FUNCTION`, +is not a JavaScript method name: `await sap.send("/ABC/MY_FUNCTION", { ... })`. +The function module has to be remote-enabled. + +### Worth knowing + +- **A BAPI does not raise.** It reports in `RETURN` (`BAPIRET2`), and a message + of type `E` or `A` means it did nothing — the sample checks it after every + call. An exception is for what did not reach the BAPI at all: no connector, + no logon, no authorization for the RFC. +- **Every user of the app calls with the RFC user's authorizations** — the SAP + system checks the RFC user, not the one in front of the screen. For more than + a demo, restrict the app with `cds.requires.cap2ui5.roles`, or use an RFC + destination with principal propagation. +- **Classic RFC is not encrypted** without SNC — keep the connection inside a + trusted network. +- **Dates arrive as ABAP keeps them**, `YYYYMMDD` (`DATS`), and `00000000` means + none; the sample shows them as ISO dates. + +## Tests + +```bash +npm test # both samples over the wire - against the mocks, and through the real protocols +npm run lint +``` + +The tests play the frontend's part — one POST per roundtrip, as +[cap2UI5/samples](https://github.com/cap2UI5/samples) does — in two files: + +- [`test/samples.test.mjs`](test/samples.test.mjs) runs both samples against the + mocks, as `cds watch` serves them, and checks every view they display with + the [abap2UI5 linter](https://github.com/abap2UI5/linter): each control and + property has to exist in UI5 1.71. +- [`test/remote.test.mjs`](test/remote.test.mjs) configures credentials, so + nothing is mocked, and fakes only the far ends: an HTTP server that answers + as S/4HANA's OData service, and a stand-in for the RFC connector below + `@sap/cds-rfc`. What it asserts is what the systems would receive — the + OData requests, the RFC parameters by kind. + +## The learning path + +| | Repository | What you learn | +|---|---|---| +| 1️⃣ | [**cap2UI5/samples**](https://github.com/cap2UI5/samples) | the abap2UI5 basics as cap2UI5 apps — bindings, events, popups, navigation | +| 2️⃣ | **cap2UI5/samples-stack** — 📍 *you are here* | how cap2UI5 plays with your stack — OData services, function modules in SAP systems | + +The same idea for ABAP is [abap2UI5/samples-stack](https://github.com/abap2UI5/samples-stack). diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..8bb9c8d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,36 @@ +// no-undef is the rule that matters here, as in cap2UI5/cap2UI5: an undefined +// identifier in a sample is a runtime error that shows only once somebody +// presses the right button. Nothing else is switched on - the tests are the +// real gate. +const host = { + process: "readonly", + console: "readonly", + Buffer: "readonly", + fetch: "readonly", + URL: "readonly", +}; + +export default [ + { + ignores: ["node_modules/**"], + }, + { + files: ["**/*.{js,mjs,cjs}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: host, + }, + rules: { + "no-undef": "error", + "no-unused-vars": ["error", { args: "none" }], + }, + }, + { + files: ["**/*.cjs"], + languageOptions: { + sourceType: "commonjs", + globals: { ...host, module: "writable", require: "readonly" }, + }, + }, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ceb4465 --- /dev/null +++ b/package.json @@ -0,0 +1,80 @@ +{ + "name": "cap2ui5-samples-stack", + "private": true, + "version": "0.1.0", + "description": "Learn how cap2UI5 plays with your stack: apps that call an OData service and a function module (BAPI) in an SAP system, through CAP's remote services - mocked in development, real once credentials are configured.", + "keywords": [ + "cap", + "cds", + "sap", + "ui5", + "abap2ui5", + "cap2ui5", + "odata", + "rfc", + "bapi", + "samples" + ], + "homepage": "https://github.com/cap2UI5/samples-stack#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/cap2UI5/samples-stack.git" + }, + "bugs": { + "url": "https://github.com/cap2UI5/samples-stack/issues" + }, + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22.15" + }, + "dependencies": { + "@cap-js/sqlite": "^3", + "@cap2ui5/cds-plugin": "^0.3.1", + "@sap/cds": "^10", + "@sap/cds-rfc": "^2.2.1" + }, + "devDependencies": { + "@abap2ui5/linter": "^0.8.3", + "@cap-js/cds-test": "^1.0.2", + "eslint": "^10", + "fast-xml-parser": "^5.11.1" + }, + "scripts": { + "start": "cds-serve", + "watch": "cds watch", + "mocked": "cds-serve --with-mocks", + "test": "node --test test/*.test.mjs", + "lint": "eslint ." + }, + "cds": { + "requires": { + "db": { + "kind": "sqlite", + "credentials": { + "url": ":memory:" + } + }, + "API_BUSINESS_PARTNER": { + "kind": "odata-v2", + "model": "srv/external/API_BUSINESS_PARTNER", + "[production]": { + "credentials": { + "destination": "S4HANA", + "path": "/sap/opu/odata/sap/API_BUSINESS_PARTNER" + } + } + }, + "SAP_RFC": { + "kind": "rfc", + "model": "srv/external/SAP_RFC", + "external": true, + "[production]": { + "credentials": { + "destination": "SAP_RFC" + } + } + } + } + } +} diff --git a/srv/apps/z2ui5_cl_caps_app_001.js b/srv/apps/z2ui5_cl_caps_app_001.js new file mode 100644 index 0000000..3c6b7ca --- /dev/null +++ b/srv/apps/z2ui5_cl_caps_app_001.js @@ -0,0 +1,285 @@ +// @keywords odata remote service cds.connect.to cds.ql api_business_partner s4hana business partner mock +// @summary Reads business partners from SAP S/4HANA's OData service with cds.ql: CAP turns the query into the OData request - against a mock of the service while you develop, against the real one once credentials are configured. +// @docs https://cap2ui5.github.io/docs/examples/external-odata +// +// A cap2UI5 app is a CAP handler, so it calls an OData service the way every +// CAP handler does, and nothing in it is cap2UI5's own: +// +// - the service's model is imported to srv/external/ (cds import), +// - it is declared in package.json, as cds.requires.API_BUSINESS_PARTNER, +// - and the app connects to it by that name and runs cds.ql against it. +// +// With credentials configured, a search for "Cust" leaves the process as +// +// GET .../API_BUSINESS_PARTNER/A_BusinessPartner?$select=BusinessPartner,... +// &$orderby=BusinessPartner&$top=20 +// &$filter=substringof('Cust',BusinessPartnerFullName)&$inlinecount=allpages +// +// Without them, cds watch MOCKS the service: it serves the same model from the +// in-memory database, filled from srv/external/data/ - so the sample runs as it +// is, and runs against your system without a changed line. +import cds from "@sap/cds"; +import { defineApp, t, z2ui5_cl_ui5_view_builder } from "@cap2ui5/cds-plugin"; + +const { SELECT } = cds.ql; + +const TOP = 20; // rows per search: $top + +// A row as the service sends it. The names are the OData properties, and the +// view binds them uppercased: {BUSINESSPARTNERFULLNAME}. +const ty_s_partner = { + BusinessPartner: "", + BusinessPartnerFullName: "", + BusinessPartnerCategory: "", + SearchTerm1: "", + CreationDate: "", +}; + +const ty_s_address = { + AddressID: "", + StreetName: "", + HouseNumber: "", + PostalCode: "", + CityName: "", + Country: "", +}; + +defineApp("Z2UI5_CL_CAPS_APP_001", class { + + search = ""; + category = ""; // BusinessPartnerCategory: 1 person, 2 organization, 3 group - "" all + partners = t.table(ty_s_partner); + count = 0; // every hit, not only the TOP shown + source = "(not connected)"; // what answered: the mock, a URL or a destination + partner = ""; // whose addresses the popup shows + addresses = t.table(ty_s_address); + + async main(client) { + + this.client = client; + + if (client.check_on_init()) { + await this.read_partners(); + this.view_display(); + + } else if (client.check_on_navigated()) { + this.view_display(); + + } else if (client.check_on_event("SEARCH")) { + await this.read_partners(); + + } else if (client.check_on_event("ADDRESSES")) { + await this.read_addresses(client.get_event_arg()); + } + + } + + // One query, written as against the project's own database. The remote + // service translates it: columns to $select, where to $filter, orderBy to + // $orderby, limit to $top, and count to $inlinecount. + async read_partners() { + + try { + + const bupa = await cds.connect.to("API_BUSINESS_PARTNER"); + const { A_BusinessPartner } = bupa.entities; + this.source = source_of(bupa); + + const query = SELECT.from(A_BusinessPartner) + .columns("BusinessPartner", "BusinessPartnerFullName", "BusinessPartnerCategory", "SearchTerm1", "CreationDate") + .orderBy("BusinessPartner") + .limit(TOP); + if (this.search) query.where`contains(BusinessPartnerFullName, ${this.search})`; + if (this.category) query.where({ BusinessPartnerCategory: this.category }); + query.SELECT.count = true; + + const partners = await bupa.run(query); + this.partners = partners; + this.count = partners.$count ?? partners.length; + + } catch (e) { + // a remote service fails in ways the own database does not - say what happened + this.client.message_box_display({ text: `Calling API_BUSINESS_PARTNER failed: ${e.message}`, type: "error" }); + } + + } + + async read_addresses(business_partner) { + + try { + + const bupa = await cds.connect.to("API_BUSINESS_PARTNER"); + const { A_BusinessPartnerAddress } = bupa.entities; + + this.addresses = await bupa.run(SELECT.from(A_BusinessPartnerAddress) + .columns("AddressID", "StreetName", "HouseNumber", "PostalCode", "CityName", "Country") + .where({ BusinessPartner: business_partner })); + this.partner = this.partners.find((p) => p.BusinessPartner === business_partner)?.BusinessPartnerFullName + ?? business_partner; + this.popup_addresses(); + + } catch (e) { + this.client.message_box_display({ text: `Calling API_BUSINESS_PARTNER failed: ${e.message}`, type: "error" }); + } + + } + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }) + .a({ n: "xmlns:core", v: "sap.ui.core" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - OData - Business Partners from SAP S/4HANA" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "Read with cds.ql from the OData service API_BUSINESS_PARTNER - " + + `connected to ${this.client._bind("source")}` }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + const table = page.ele("Table") + .a({ n: "items", v: this.client._bind("partners") }) + .a({ n: "noDataText", v: "No business partner found" }); + + const toolbar = table.ele("headerToolbar") + .ele("OverflowToolbar"); + toolbar.tag("Title") + .a({ n: "text", v: `Business Partners (${this.client._bind("count")})` }); + toolbar.tag("ToolbarSpacer"); + toolbar.ele("Select") + .a({ n: "selectedKey", v: this.client._bind("category") }) + .a({ n: "change", v: this.client._event("SEARCH") }) + .tag({ n: "Item", ns: "core" }) + .a({ n: "key", v: "" }) + .a({ n: "text", v: "All Categories" }) + .tag({ n: "Item", ns: "core" }) + .a({ n: "key", v: "1" }) + .a({ n: "text", v: "Person" }) + .tag({ n: "Item", ns: "core" }) + .a({ n: "key", v: "2" }) + .a({ n: "text", v: "Organization" }) + .tag({ n: "Item", ns: "core" }) + .a({ n: "key", v: "3" }) + .a({ n: "text", v: "Group" }); + toolbar.tag("SearchField") + .a({ n: "value", v: this.client._bind("search") }) + .a({ n: "search", v: this.client._event("SEARCH") }) + .a({ n: "placeholder", v: "Name contains" }) + .a({ n: "width", v: "16rem" }); + + table.ele("columns") + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Business Partner" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Name" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Category" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Search Term" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Created On" }); + + // a row press asks for that partner's addresses - a second request + table.ele("items") + .ele("ColumnListItem") + .a({ n: "type", v: "Navigation" }) + .a({ n: "press", v: this.client._event({ val: "ADDRESSES", arg: "${BUSINESSPARTNER}" }) }) + .ele("cells") + .tag("Text") + .a({ n: "text", v: "{BUSINESSPARTNER}" }) + .tag("Text") + .a({ n: "text", v: "{BUSINESSPARTNERFULLNAME}" }) + .tag("Text") + .a({ n: "text", v: "{= ${BUSINESSPARTNERCATEGORY} === '1' ? 'Person' : " + + "${BUSINESSPARTNERCATEGORY} === '2' ? 'Organization' : 'Group' }" }) + .tag("Text") + .a({ n: "text", v: "{SEARCHTERM1}" }) + .tag("Text") + .a({ n: "text", v: "{CREATIONDATE}" }); + + this.client.view_display(view.stringify()); + + } + + popup_addresses() { + + const popup = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "FragmentDefinition", ns: "core" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:core", v: "sap.ui.core" }); + const dialog = popup.ele("Dialog") + .a({ n: "title", v: `Addresses - ${this.client._bind("partner")}` }) + .a({ n: "contentWidth", v: "40rem" }); + + const table = dialog.ele("Table") + .a({ n: "items", v: this.client._bind("addresses") }) + .a({ n: "noDataText", v: "No address" }); + + table.ele("columns") + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Street" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Postal Code" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "City" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Country" }); + + table.ele("items") + .ele("ColumnListItem") + .ele("cells") + .tag("Text") + .a({ n: "text", v: "{STREETNAME} {HOUSENUMBER}" }) + .tag("Text") + .a({ n: "text", v: "{POSTALCODE}" }) + .tag("Text") + .a({ n: "text", v: "{CITYNAME}" }) + .tag("Text") + .a({ n: "text", v: "{COUNTRY}" }); + + dialog.ele("buttons") + .tag("Button") + .a({ n: "text", v: "Close" }) + .a({ n: "type", v: "Emphasized" }) + .a({ n: "press", v: this.client.follow_up_action(this.client.cs_event.popup_close) }); + + this.client.popup_display(popup.stringify()); + + } +}); + +// What answers the queries - shown on the screen, so it is plain whether a +// search reached the mock or a system: a URL without user info and query, a +// destination by its name, and never a credential. +function source_of(srv) { + if (srv.mocked) return `the mock of ${srv.name} - cds watch serves it while no credentials are configured`; + const { url, destination } = srv.options.credentials ?? {}; + if (destination) return `destination ${destination}`; + const u = new URL(url); + return u.origin + u.pathname; +} diff --git a/srv/apps/z2ui5_cl_caps_app_002.js b/srv/apps/z2ui5_cl_caps_app_002.js new file mode 100644 index 0000000..877c8c6 --- /dev/null +++ b/srv/apps/z2ui5_cl_caps_app_002.js @@ -0,0 +1,362 @@ +// @keywords rfc bapi function module fuba abap sap system cds-rfc node-rfc bapi_user_getlist bapi_user_get_detail bapiret2 +// @summary Calls two BAPIs in an SAP system over RFC with @sap/cds-rfc - users with BAPI_USER_GETLIST, one user's details with BAPI_USER_GET_DETAIL - single values, structures and tables in both directions, errors from RETURN. +// +// To CAP, a function module of an SAP system is an action of a remote service, +// and that is what @sap/cds-rfc, SAP's RFC plugin for CAP, makes of it: +// srv/external/SAP_RFC.cds declares BAPI_USER_GETLIST and BAPI_USER_GET_DETAIL +// as actions of the service SAP_RFC, package.json declares the service as +// cds.requires.SAP_RFC (kind rfc), and the app calls a BAPI like a method: +// +// const sap = await cds.connect.to("SAP_RFC"); +// const { ADDRESS, ACTIVITYGROUPS, RETURN } = await sap.BAPI_USER_GET_DETAIL({ USERNAME: "DEVELOPER" }); +// +// The parameters go by their ABAP names: a single value is a value, a +// structure an object, a table an array of objects - in and out. That is +// node-rfc's client.call("BAPI_USER_GET_DETAIL", { USERNAME }), with the +// connection moved into CAP's configuration: an RFC destination on SAP BTP, a +// .env file on your machine - and a mock while you develop, +// srv/external/SAP_RFC.js, which cds watch serves while no credentials are +// configured. The README says how to connect a system. +import cds from "@sap/cds"; +import { defineApp, t, z2ui5_cl_ui5_view_builder } from "@cap2ui5/cds-plugin"; + +const MAX_ROWS = 50; // BAPI_USER_GETLIST's MAX_ROWS - 0 would mean all + +// BAPILOGOND-USTYP +const USER_TYPES = { A: "Dialog", B: "System", C: "Communication", L: "Reference", S: "Service" }; + +// a row of USERLIST, BAPIUSNAME - the view binds {USERNAME}, {FULLNAME}, ... +const ty_s_user = { + USERNAME: "", + FIRSTNAME: "", + LASTNAME: "", + FULLNAME: "", +}; + +// a row of ACTIVITYGROUPS, BAPIAGR - the dates as the view shows them +const ty_s_role = { + AGR_NAME: "", + AGR_TEXT: "", + FROM_DAT: "", + TO_DAT: "", +}; + +defineApp("Z2UI5_CL_CAPS_APP_002", class { + + pattern = "*"; // a user name - * and + are wildcards + rows = 0; // ROWS + users = t.table(ty_s_user); // USERLIST + source = "(not connected)"; // what answered: the mock, a host or a destination + user = { // one user, from ADDRESS, LOGONDATA and ISLOCKED + USERNAME: "", + FULLNAME: "", + DEPARTMENT: "", + FUNCTION: "", + E_MAIL: "", + TEL1_NUMBR: "", + USTYP: "", + CLASS: "", + GLTGV: "", + GLTGB: "", + LOCKED: false, + }; + roles = t.table(ty_s_role); // ACTIVITYGROUPS + + async main(client) { + + this.client = client; + + if (client.check_on_init()) { + await this.read_users(); + this.view_display(); + + } else if (client.check_on_navigated()) { + this.view_display(); + + } else if (client.check_on_event("SEARCH")) { + await this.read_users(); + + } else if (client.check_on_event("DETAIL")) { + await this.read_user(client.get_event_arg()); + } + + } + + // BAPI_USER_GETLIST: two single values and a table in, a single value and + // tables out. SELECTION_RANGE is a ranges table as ABAP knows it - SIGN, + // OPTION, LOW, HIGH - and OPTION CP a pattern, as in a SELECT-OPTIONS. + async read_users() { + + try { + + const sap = await cds.connect.to("SAP_RFC"); + this.source = source_of(sap); + + const pattern = this.pattern.trim().toUpperCase(); // user names are upper case + const { ROWS, USERLIST, RETURN } = await sap.BAPI_USER_GETLIST({ + MAX_ROWS, + WITH_USERNAME: "X", // abap_true: the names too, not only the user IDs + SELECTION_RANGE: pattern ? [ { PARAMETER: "USERNAME", SIGN: "I", OPTION: "CP", LOW: pattern } ] : [] }); + if (this.failed(RETURN)) return; + + this.rows = ROWS; + this.users = USERLIST; + + } catch (e) { + // no connector, no logon, no authorization: the call itself failed + this.client.message_box_display({ text: `Calling SAP_RFC failed: ${e.message}`, type: "error" }); + } + + } + + // BAPI_USER_GET_DETAIL: a single value in, structures and tables out. + async read_user(username) { + + try { + + const sap = await cds.connect.to("SAP_RFC"); + + const { ADDRESS, LOGONDATA, ISLOCKED, ACTIVITYGROUPS, RETURN } = await sap.BAPI_USER_GET_DETAIL({ + USERNAME: username }); + if (this.failed(RETURN)) return; + + this.user = { + USERNAME: username, + FULLNAME: ADDRESS.FULLNAME, + DEPARTMENT: ADDRESS.DEPARTMENT, + FUNCTION: ADDRESS.FUNCTION, + E_MAIL: ADDRESS.E_MAIL, + TEL1_NUMBR: ADDRESS.TEL1_NUMBR, + USTYP: USER_TYPES[LOGONDATA.USTYP] ?? LOGONDATA.USTYP, + CLASS: LOGONDATA.CLASS, + GLTGV: dats(LOGONDATA.GLTGV), + GLTGB: dats(LOGONDATA.GLTGB), + LOCKED: [ ISLOCKED.LOCAL_LOCK, ISLOCKED.GLOB_LOCK, ISLOCKED.WRNG_LOGON ].includes("L"), + }; + this.roles = ACTIVITYGROUPS.map((r) => ({ + AGR_NAME: r.AGR_NAME, + AGR_TEXT: r.AGR_TEXT, + FROM_DAT: dats(r.FROM_DAT), + TO_DAT: dats(r.TO_DAT), + })); + this.popup_user(); + + } catch (e) { + this.client.message_box_display({ text: `Calling SAP_RFC failed: ${e.message}`, type: "error" }); + } + + } + + // A BAPI does not raise an exception: it reports in RETURN (BAPIRET2), and a + // message of TYPE E or A means it did nothing. So every call checks it. + failed(RETURN = []) { + + const errors = RETURN.filter((m) => m.TYPE === "E" || m.TYPE === "A"); + if (errors.length) { + this.client.message_box_display({ text: errors.map((m) => m.MESSAGE).join("\n"), type: "error" }); + } + return errors.length > 0; + + } + + view_display() { + + const view = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "View", ns: "mvc" }) + .a({ n: "displayBlock", v: "true" }) + .a({ n: "height", v: "100%" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:mvc", v: "sap.ui.core.mvc" }); + const page = view.ele("Shell") + .ele("Page") + .a({ n: "title", v: "cap2UI5 - RFC - Call BAPIs in an SAP System" }) + .a({ n: "showNavButton", b: this.client.check_app_prev_stack() }) + .a({ n: "navButtonPress", v: this.client._event_nav_app_leave() }); + + page.tag("MessageStrip") + .a({ n: "text", v: "BAPI_USER_GETLIST and BAPI_USER_GET_DETAIL, called over RFC with @sap/cds-rfc - " + + `connected to ${this.client._bind("source")}` }) + .a({ n: "type", v: "Information" }) + .a({ n: "showIcon", b: true }) + .a({ n: "class", v: "sapUiSmallMargin" }); + + const table = page.ele("Table") + .a({ n: "items", v: this.client._bind("users") }) + .a({ n: "noDataText", v: "No user found" }); + + const toolbar = table.ele("headerToolbar") + .ele("OverflowToolbar"); + toolbar.tag("Title") + .a({ n: "text", v: `Users (${this.client._bind("rows")})` }); + toolbar.tag("ToolbarSpacer"); + toolbar.tag("SearchField") + .a({ n: "value", v: this.client._bind("pattern") }) + .a({ n: "search", v: this.client._event("SEARCH") }) + .a({ n: "placeholder", v: "User name, * as wildcard" }) + .a({ n: "width", v: "16rem" }); + + table.ele("columns") + .ele("Column") + .tag("Text") + .a({ n: "text", v: "User" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Full Name" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "First Name" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Last Name" }); + + // a row press reads that user in full - the second BAPI + table.ele("items") + .ele("ColumnListItem") + .a({ n: "type", v: "Navigation" }) + .a({ n: "press", v: this.client._event({ val: "DETAIL", arg: "${USERNAME}" }) }) + .ele("cells") + .tag("Text") + .a({ n: "text", v: "{USERNAME}" }) + .tag("Text") + .a({ n: "text", v: "{FULLNAME}" }) + .tag("Text") + .a({ n: "text", v: "{FIRSTNAME}" }) + .tag("Text") + .a({ n: "text", v: "{LASTNAME}" }); + + this.client.view_display(view.stringify()); + + } + + popup_user() { + + const popup = z2ui5_cl_ui5_view_builder.factory() + .ele({ n: "FragmentDefinition", ns: "core" }) + .a({ n: "xmlns", v: "sap.m" }) + .a({ n: "xmlns:core", v: "sap.ui.core" }) + .a({ n: "xmlns:form", v: "sap.ui.layout.form" }); + const dialog = popup.ele("Dialog") + .a({ n: "title", v: `User ${this.client._bind("user-USERNAME")}` }) + .a({ n: "contentWidth", v: "40rem" }); + + // the export structures ADDRESS, LOGONDATA and ISLOCKED + const form = dialog.ele({ n: "SimpleForm", ns: "form" }) + .a({ n: "editable", b: false }) + .a({ n: "layout", v: "ResponsiveGridLayout" }) + .ele({ n: "content", ns: "form" }); + + form.tag({ n: "Title", ns: "core" }) + .a({ n: "text", v: "Address" }); + form.tag("Label") + .a({ n: "text", v: "Full Name" }); + form.tag("Text") + .a({ n: "text", v: this.client._bind("user-FULLNAME") }); + form.tag("Label") + .a({ n: "text", v: "Department" }); + form.tag("Text") + .a({ n: "text", v: this.client._bind("user-DEPARTMENT") }); + form.tag("Label") + .a({ n: "text", v: "Function" }); + form.tag("Text") + .a({ n: "text", v: this.client._bind("user-FUNCTION") }); + form.tag("Label") + .a({ n: "text", v: "E-Mail" }); + form.tag("Text") + .a({ n: "text", v: this.client._bind("user-E_MAIL") }); + form.tag("Label") + .a({ n: "text", v: "Telephone" }); + form.tag("Text") + .a({ n: "text", v: this.client._bind("user-TEL1_NUMBR") }); + + form.tag({ n: "Title", ns: "core" }) + .a({ n: "text", v: "Logon Data" }); + form.tag("Label") + .a({ n: "text", v: "User Type" }); + form.tag("Text") + .a({ n: "text", v: this.client._bind("user-USTYP") }); + form.tag("Label") + .a({ n: "text", v: "User Group" }); + form.tag("Text") + .a({ n: "text", v: this.client._bind("user-CLASS") }); + form.tag("Label") + .a({ n: "text", v: "Valid From - To" }); + form.tag("Text") + .a({ n: "text", v: `${this.client._bind("user-GLTGV")} - ${this.client._bind("user-GLTGB")}` }); + form.tag("Label") + .a({ n: "text", v: "Status" }); + form.tag("ObjectStatus") + .a({ n: "text", v: `{= $${this.client._bind("user-LOCKED")} ? 'Locked' : 'Not locked' }` }) + .a({ n: "state", v: `{= $${this.client._bind("user-LOCKED")} ? 'Error' : 'Success' }` }); + + // the table ACTIVITYGROUPS + const table = dialog.ele("Table") + .a({ n: "items", v: this.client._bind("roles") }) + .a({ n: "noDataText", v: "No role" }); + + table.ele("headerToolbar") + .ele("Toolbar") + .tag("Title") + .a({ n: "text", v: "Roles" }); + + table.ele("columns") + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Role" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Description" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Valid From" }) + .end() + .ele("Column") + .tag("Text") + .a({ n: "text", v: "Valid To" }); + + table.ele("items") + .ele("ColumnListItem") + .ele("cells") + .tag("Text") + .a({ n: "text", v: "{AGR_NAME}" }) + .tag("Text") + .a({ n: "text", v: "{AGR_TEXT}" }) + .tag("Text") + .a({ n: "text", v: "{FROM_DAT}" }) + .tag("Text") + .a({ n: "text", v: "{TO_DAT}" }); + + dialog.ele("buttons") + .tag("Button") + .a({ n: "text", v: "Close" }) + .a({ n: "type", v: "Emphasized" }) + .a({ n: "press", v: this.client.follow_up_action(this.client.cs_event.popup_close) }); + + this.client.popup_display(popup.stringify()); + + } +}); + +// A DATS value for the view: YYYYMMDD as ISO date, an initial one (00000000) +// as nothing. Connectors deliver DATS as the string ABAP keeps; whatever else +// arrives - an ISO date, a Date - is shown as a date as well. +function dats(value) { + if (value instanceof Date) return value.toISOString().slice(0, 10); + const s = String(value ?? ""); + if (!/^\d{8}$/.test(s)) return s; + return s === "00000000" ? "" : `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6)}`; +} + +// What answers the calls - shown on the screen, so it is plain whether a call +// reached the mock or a system: a destination by its name, a system by its +// host and client, and never user or password. +function source_of(srv) { + if (srv.mocked) return `the mock of ${srv.name} - cds watch serves it while no credentials are configured`; + const { destination, ashost, mshost, client } = srv.options.credentials ?? {}; + if (destination) return `destination ${destination}`; + return `${ashost ?? mshost}, client ${client}`; +} diff --git a/srv/external/API_BUSINESS_PARTNER.cds b/srv/external/API_BUSINESS_PARTNER.cds new file mode 100644 index 0000000..9489fc5 --- /dev/null +++ b/srv/external/API_BUSINESS_PARTNER.cds @@ -0,0 +1,40 @@ +// The part of SAP S/4HANA's OData V2 service API_BUSINESS_PARTNER that sample +// Z2UI5_CL_CAPS_APP_001 reads: two of its entity sets and the properties the +// sample selects, named and typed as in the service's $metadata and written +// the way `cds import` writes them. For the whole service, import its metadata +// instead - the EDMX from the SAP Business Accelerator Hub, or $metadata of +// your own system: +// +// cds import API_BUSINESS_PARTNER.edmx --as cds --force +// +// The model is all CAP needs of a remote service. The remote service builds +// the OData requests from it, and cds watch MOCKS the service from it while no +// credentials are configured: the entities become tables of the in-memory +// database, filled from data/API_BUSINESS_PARTNER-*.csv. +@cds.external : true +service API_BUSINESS_PARTNER { + + @cds.external : true + @cds.persistence.skip : true + entity A_BusinessPartner { + key BusinessPartner : String(10) not null; + BusinessPartnerCategory : String(1); + BusinessPartnerFullName : String(81); + SearchTerm1 : String(20); + @sap.display.format : 'Date' + CreationDate : Date; + }; + + @cds.external : true + @cds.persistence.skip : true + entity A_BusinessPartnerAddress { + key BusinessPartner : String(10) not null; + key AddressID : String(10) not null; + StreetName : String(60); + HouseNumber : String(10); + PostalCode : String(10); + CityName : String(40); + Country : String(3); + }; + +}; diff --git a/srv/external/SAP_RFC.cds b/srv/external/SAP_RFC.cds new file mode 100644 index 0000000..a07f348 --- /dev/null +++ b/srv/external/SAP_RFC.cds @@ -0,0 +1,150 @@ +// The RFC interface of an SAP system, as far as sample Z2UI5_CL_CAPS_APP_002 +// calls it: two BAPIs that every ABAP system has, BAPI_USER_GETLIST and +// BAPI_USER_GET_DETAIL. Written in the shape `cds import --from rfc` writes - +// the function module is an action of the service, its import and table +// parameters are the action's parameters, and what it exports and returns in +// its tables is the result type - trimmed to the parameters and fields the +// sample uses. For a function module's whole interface, import it from your +// system (the connection as in the README, then): +// +// cds import --from rfc --as cds --name BAPI_USER_GET_DETAIL --destination SAP_RFC --force +// +// @sap/cds-rfc passes the parameters an action declares - by their +// @RFCParameterType, as import, changing or table parameters - and the +// connector checks the values against the function module's interface in the +// system. So this model decides WHICH parameters the app can pass; a parameter +// it does not declare never reaches the system. While no credentials are +// configured, cds watch mocks the service with SAP_RFC.js next to this file. +@cds.external : true +@protocol : 'rfc' +service SAP_RFC { + action BAPI_USER_GETLIST( + /** Maximum Number of Lines of Hits */ + @RFCParameterType : 'Import' + MAX_ROWS : Integer, + /** Read User with Name */ + @RFCParameterType : 'Import' + WITH_USERNAME : String(1), + /** Search for Users with a Ranges Table */ + @RFCParameterType : 'Table' + SELECTION_RANGE : many DDIC.BAPIUSSRGE, + /** User List */ + @RFCParameterType : 'Table' + USERLIST : many DDIC.BAPIUSNAME, + /** Return Parameter */ + @RFCParameterType : 'Table' + RETURN : many DDIC.BAPIRET2 + ) returns BAPI_USER_GETLIST.ResultType; + + type BAPI_USER_GETLIST.ResultType { + /** No. of users selected */ + @RFCParameterType : 'Export' + ROWS : Integer; + /** Search for Users with a Ranges Table */ + @RFCParameterType : 'Table' + SELECTION_RANGE : many DDIC.BAPIUSSRGE; + /** User List */ + @RFCParameterType : 'Table' + USERLIST : many DDIC.BAPIUSNAME; + /** Return Parameter */ + @RFCParameterType : 'Table' + RETURN : many DDIC.BAPIRET2; + }; + + action BAPI_USER_GET_DETAIL( + /** User Name */ + @RFCParameterType : 'Import' + USERNAME : String(12) not null, + /** Activity Groups */ + @RFCParameterType : 'Table' + ACTIVITYGROUPS : many DDIC.BAPIAGR, + /** Return Structure */ + @RFCParameterType : 'Table' + RETURN : many DDIC.BAPIRET2 + ) returns BAPI_USER_GET_DETAIL.ResultType; + + type BAPI_USER_GET_DETAIL.ResultType { + /** Structure with Logon Data */ + @RFCParameterType : 'Export' + LOGONDATA : DDIC.BAPILOGOND; + /** Address Data */ + @RFCParameterType : 'Export' + ADDRESS : DDIC.BAPIADDR3; + /** User Lock */ + @RFCParameterType : 'Export' + ISLOCKED : DDIC.BAPISLOCKD; + /** Activity Groups */ + @RFCParameterType : 'Table' + ACTIVITYGROUPS : many DDIC.BAPIAGR; + /** Return Structure */ + @RFCParameterType : 'Table' + RETURN : many DDIC.BAPIRET2; + }; + + type DDIC.BAPIUSSRGE { + PARAMETER : String(32); + FIELD : String(30); + SIGN : String(1); + OPTION : String(2); + LOW : String(132); + HIGH : String(132); + }; + + type DDIC.BAPIUSNAME { + USERNAME : String(12); + FIRSTNAME : String(40); + LASTNAME : String(40); + FULLNAME : String(80); + }; + + type DDIC.BAPIRET2 { + TYPE : String(1); + ID : String(20); + @RFCAbapType : 'N' + NUMBER : String(3); + MESSAGE : String(220); + LOG_NO : String(20); + @RFCAbapType : 'N' + LOG_MSG_NO : String(6); + MESSAGE_V1 : String(50); + MESSAGE_V2 : String(50); + MESSAGE_V3 : String(50); + MESSAGE_V4 : String(50); + PARAMETER : String(32); + ROW : Integer; + FIELD : String(30); + SYSTEM : String(10); + }; + + type DDIC.BAPILOGOND { + GLTGV : Date; + GLTGB : Date; + USTYP : String(1); + CLASS : String(12); + }; + + type DDIC.BAPIADDR3 { + FIRSTNAME : String(40); + LASTNAME : String(40); + FULLNAME : String(80); + DEPARTMENT : String(40); + FUNCTION : String(40); + TEL1_NUMBR : String(30); + E_MAIL : String(241); + }; + + type DDIC.BAPISLOCKD { + WRNG_LOGON : String(1); + LOCAL_LOCK : String(1); + GLOB_LOCK : String(1); + NO_USER_PW : String(1); + }; + + type DDIC.BAPIAGR { + AGR_NAME : String(30); + FROM_DAT : Date; + TO_DAT : Date; + AGR_TEXT : String(80); + ORG_FLAG : String(1); + }; +}; diff --git a/srv/external/SAP_RFC.js b/srv/external/SAP_RFC.js new file mode 100644 index 0000000..b6b6351 --- /dev/null +++ b/srv/external/SAP_RFC.js @@ -0,0 +1,140 @@ +// SAP_RFC, mocked: what CAP serves in place of the SAP system while no +// credentials are configured - in cds watch and in the tests. CAP finds it the +// way it finds any service implementation, by its name next to the model +// (SAP_RFC.cds), and it answers the two BAPIs of sample Z2UI5_CL_CAPS_APP_002 +// the way an ABAP system does: the same parameters and tables, dates as DATS +// (YYYYMMDD), and a failure in RETURN rather than as an exception - over a +// handful of made-up users. +// +// CAP takes this file ONLY while the service is mocked. With credentials +// configured, @sap/cds-rfc calls the system instead - which is what +// `"external": true` in cds.requires.SAP_RFC (package.json) is for: the rfc +// kind does not declare itself external, and without the flag CAP would keep +// choosing this file over the connector, whatever the credentials say. +import cds from "@sap/cds"; + +const USERS = [ + { USERNAME: "ASCHMIDT", FIRSTNAME: "Anna", LASTNAME: "Schmidt", FULLNAME: "Anna Schmidt", + DEPARTMENT: "Sales", FUNCTION: "Sales Representative", TEL1_NUMBR: "+49 6227 100", E_MAIL: "anna.schmidt@example.com", + USTYP: "A", CLASS: "SALES", GLTGV: "20220301", GLTGB: "00000000", LOCK: "", + ROLES: [ + { AGR_NAME: "Z_SALES_ORDERS", AGR_TEXT: "Process sales orders", FROM_DAT: "20220301", TO_DAT: "99991231" }, + { AGR_NAME: "Z_CUSTOMERS_DSP", AGR_TEXT: "Display customers", FROM_DAT: "20220301", TO_DAT: "99991231" }, + ] }, + { USERNAME: "BATCH_JOBS", FIRSTNAME: "", LASTNAME: "Batch Jobs", FULLNAME: "Batch Jobs", + DEPARTMENT: "IT", FUNCTION: "", TEL1_NUMBR: "", E_MAIL: "", + USTYP: "B", CLASS: "SYSTEM", GLTGV: "20190101", GLTGB: "00000000", LOCK: "", + ROLES: [ + { AGR_NAME: "Z_BACKGROUND_JOBS", AGR_TEXT: "Run background jobs", FROM_DAT: "20190101", TO_DAT: "99991231" }, + ] }, + { USERNAME: "DEVELOPER", FIRSTNAME: "", LASTNAME: "Developer", FULLNAME: "Developer", + DEPARTMENT: "IT", FUNCTION: "ABAP Developer", TEL1_NUMBR: "+49 6227 200", E_MAIL: "developer@example.com", + USTYP: "A", CLASS: "DEVELOPER", GLTGV: "20240101", GLTGB: "00000000", LOCK: "", + ROLES: [ + { AGR_NAME: "Z_ABAP_DEVELOPER", AGR_TEXT: "Develop in ABAP", FROM_DAT: "20240101", TO_DAT: "99991231" }, + { AGR_NAME: "Z_DISPLAY_ALL", AGR_TEXT: "Display everything", FROM_DAT: "20240101", TO_DAT: "20261231" }, + ] }, + { USERNAME: "JMILLER", FIRSTNAME: "John", LASTNAME: "Miller", FULLNAME: "John Miller", + DEPARTMENT: "Purchasing", FUNCTION: "Purchaser", TEL1_NUMBR: "+1 312 555 0188", E_MAIL: "john.miller@example.com", + USTYP: "A", CLASS: "PURCHASING", GLTGV: "20220302", GLTGB: "20261231", LOCK: "LOCAL_LOCK", + ROLES: [ + { AGR_NAME: "Z_PURCHASE_ORDERS", AGR_TEXT: "Process purchase orders", FROM_DAT: "20220302", TO_DAT: "20261231" }, + ] }, + { USERNAME: "KTANAKA", FIRSTNAME: "Kenji", LASTNAME: "Tanaka", FULLNAME: "Kenji Tanaka", + DEPARTMENT: "Finance", FUNCTION: "Accountant", TEL1_NUMBR: "+81 3 5555 0142", E_MAIL: "kenji.tanaka@example.com", + USTYP: "A", CLASS: "FINANCE", GLTGV: "20240214", GLTGB: "00000000", LOCK: "WRNG_LOGON", + ROLES: [ + { AGR_NAME: "Z_GL_ACCOUNTING", AGR_TEXT: "General ledger accounting", FROM_DAT: "20240214", TO_DAT: "99991231" }, + ] }, + { USERNAME: "MGARCIA", FIRSTNAME: "Maria", LASTNAME: "Garcia", FULLNAME: "Maria Garcia", + DEPARTMENT: "Sales", FUNCTION: "Sales Manager", TEL1_NUMBR: "+34 91 555 0123", E_MAIL: "maria.garcia@example.com", + USTYP: "A", CLASS: "SALES", GLTGV: "20231120", GLTGB: "00000000", LOCK: "", + ROLES: [ + { AGR_NAME: "Z_SALES_ORDERS", AGR_TEXT: "Process sales orders", FROM_DAT: "20231120", TO_DAT: "99991231" }, + { AGR_NAME: "Z_SALES_MANAGER", AGR_TEXT: "Approve sales orders", FROM_DAT: "20231120", TO_DAT: "99991231" }, + ] }, + { USERNAME: "RFC_CAP", FIRSTNAME: "", LASTNAME: "RFC User for CAP", FULLNAME: "RFC User for CAP", + DEPARTMENT: "IT", FUNCTION: "", TEL1_NUMBR: "", E_MAIL: "", + USTYP: "C", CLASS: "SYSTEM", GLTGV: "20250101", GLTGB: "00000000", LOCK: "", + ROLES: [ + { AGR_NAME: "Z_RFC_USER_DISPLAY", AGR_TEXT: "Read users over RFC", FROM_DAT: "20250101", TO_DAT: "99991231" }, + ] }, +]; + +export default class SAP_RFC extends cds.ApplicationService { + + init() { + + // MAX_ROWS 0 means all; USERLIST carries the names only WITH_USERNAME = X. + // Of SELECTION_RANGE the mock reads PARAMETER USERNAME - the real BAPI + // reads every parameter of BAPI_USER_GET_DETAIL (ADDRESS, LOGONDATA, ...). + this.on("BAPI_USER_GETLIST", (req) => { + const { MAX_ROWS = 0, WITH_USERNAME = "", SELECTION_RANGE = [] } = req.data; + const ranges = SELECTION_RANGE.filter((r) => r.PARAMETER === "USERNAME"); + const hits = USERS.filter((u) => in_range(u.USERNAME, ranges)).slice(0, MAX_ROWS > 0 ? MAX_ROWS : undefined); + return { + ROWS: hits.length, + SELECTION_RANGE, + USERLIST: hits.map((u) => ({ + USERNAME: u.USERNAME, + FIRSTNAME: WITH_USERNAME === "X" ? u.FIRSTNAME : "", + LASTNAME: WITH_USERNAME === "X" ? u.LASTNAME : "", + FULLNAME: WITH_USERNAME === "X" ? u.FULLNAME : "", + })), + RETURN: [], + }; + }); + + // An unknown user is not an exception: the export structures stay initial + // and RETURN says why, as the ABAP system answers it. + this.on("BAPI_USER_GET_DETAIL", (req) => { + const u = USERS.find((user) => user.USERNAME === req.data.USERNAME); + if (!u) { + return { + LOGONDATA: {}, + ADDRESS: {}, + ISLOCKED: {}, + ACTIVITYGROUPS: [], + RETURN: [bapiret2("E", "01", "124", `User ${req.data.USERNAME} does not exist`, req.data.USERNAME)], + }; + } + const lock = (flag) => (u.LOCK === flag ? "L" : "U"); + return { + LOGONDATA: { GLTGV: u.GLTGV, GLTGB: u.GLTGB, USTYP: u.USTYP, CLASS: u.CLASS }, + ADDRESS: { FIRSTNAME: u.FIRSTNAME, LASTNAME: u.LASTNAME, FULLNAME: u.FULLNAME, DEPARTMENT: u.DEPARTMENT, + FUNCTION: u.FUNCTION, TEL1_NUMBR: u.TEL1_NUMBR, E_MAIL: u.E_MAIL }, + ISLOCKED: { WRNG_LOGON: lock("WRNG_LOGON"), LOCAL_LOCK: lock("LOCAL_LOCK"), GLOB_LOCK: lock("GLOB_LOCK"), NO_USER_PW: "U" }, + ACTIVITYGROUPS: u.ROLES.map((r) => ({ ...r, ORG_FLAG: "" })), + RETURN: [], + }; + }); + + return super.init(); + } + +} + +// SELECTION_RANGE for one parameter, as ABAP evaluates a range: included when +// an I row matches (or there is none), unless an E row matches. OPTION EQ, NE, +// BT and CP - * for any string, + for one character. +function in_range(value, ranges) { + const hit = ({ OPTION, LOW = "", HIGH = "" }) => { + switch (OPTION) { + case "EQ": return value === LOW; + case "NE": return value !== LOW; + case "BT": return value >= LOW && value <= HIGH; + case "CP": return new RegExp(`^${LOW.replace(/[.*+?^${}()|[\]\\]/g, + (c) => (c === "*" ? ".*" : c === "+" ? "." : `\\${c}`))}$`).test(value); + default: return false; + } + }; + const including = ranges.filter((r) => r.SIGN !== "E"); + const excluding = ranges.filter((r) => r.SIGN === "E"); + return (!including.length || including.some(hit)) && !excluding.some(hit); +} + +// one row of BAPIRET2, the return structure every BAPI reports in +function bapiret2(TYPE, ID, NUMBER, MESSAGE, MESSAGE_V1 = "") { + return { TYPE, ID, NUMBER, MESSAGE, LOG_NO: "", LOG_MSG_NO: "000000", MESSAGE_V1, MESSAGE_V2: "", MESSAGE_V3: "", + MESSAGE_V4: "", PARAMETER: "", ROW: 0, FIELD: "", SYSTEM: "" }; +} diff --git a/srv/external/data/API_BUSINESS_PARTNER-A_BusinessPartner.csv b/srv/external/data/API_BUSINESS_PARTNER-A_BusinessPartner.csv new file mode 100644 index 0000000..17d3289 --- /dev/null +++ b/srv/external/data/API_BUSINESS_PARTNER-A_BusinessPartner.csv @@ -0,0 +1,15 @@ +BusinessPartner;BusinessPartnerCategory;BusinessPartnerFullName;SearchTerm1;CreationDate +1000000;2;Inlandskunde DE 1;KUNDE_DE1;2019-02-11 +1000001;2;Domestic Customer US 1;CUST_US1;2019-02-11 +1000002;2;Domestic Customer US 2;CUST_US2;2019-02-12 +1000010;2;Bike Sales GmbH;BIKESALES;2020-06-03 +1000011;2;Blue Harbour Logistics Ltd.;BLUEHARBOUR;2021-09-14 +1000012;2;Cafe Central Wien;CAFECENTRAL;2022-05-30 +1000020;1;Anna Schmidt;SCHMIDT;2022-03-01 +1000021;1;John Miller;MILLER;2022-03-02 +1000022;1;Maria Garcia;GARCIA;2023-11-20 +1000023;1;Kenji Tanaka;TANAKA;2024-02-14 +1000030;3;Purchasing Group North;PG_NORTH;2024-01-08 +17100001;2;Domestic Supplier US 1;SUPPL_US1;2019-05-02 +17100002;2;Domestic Supplier US 2;SUPPL_US2;2019-05-02 +17300001;2;Inlandslieferant DE 1;LIEF_DE1;2019-05-06 diff --git a/srv/external/data/API_BUSINESS_PARTNER-A_BusinessPartnerAddress.csv b/srv/external/data/API_BUSINESS_PARTNER-A_BusinessPartnerAddress.csv new file mode 100644 index 0000000..7d608a9 --- /dev/null +++ b/srv/external/data/API_BUSINESS_PARTNER-A_BusinessPartnerAddress.csv @@ -0,0 +1,15 @@ +BusinessPartner;AddressID;StreetName;HouseNumber;PostalCode;CityName;Country +1000000;22786;Musterstrasse;16;69190;Walldorf;DE +1000001;22787;Main Street;1200;19073;Springfield;US +1000002;22788;Harbor Boulevard;45;92801;Anaheim;US +1000010;22801;Radweg;7;10115;Berlin;DE +1000010;22802;Lagerstrasse;21;04109;Leipzig;DE +1000011;22803;Quay Road;3;SW1A 1AA;London;GB +1000012;22804;Kaffeegasse;14;1010;Wien;AT +1000020;22810;Hauptstrasse;5;80331;Muenchen;DE +1000021;22811;Oak Avenue;88;60601;Chicago;US +1000022;22812;Calle Mayor;12;28013;Madrid;ES +1000023;22813;Sakura-dori;2;104-0061;Tokyo;JP +17100001;22820;Industrial Park;500;30303;Atlanta;US +17100002;22821;Commerce Drive;77;75201;Dallas;US +17300001;22822;Werkstrasse;9;70173;Stuttgart;DE diff --git a/test/fixtures/node-rfc-library.cjs b/test/fixtures/node-rfc-library.cjs new file mode 100644 index 0000000..c238bbb --- /dev/null +++ b/test/fixtures/node-rfc-library.cjs @@ -0,0 +1,49 @@ +// A stand-in for @sap-rfc/node-rfc-library, the connector @sap/cds-rfc talks +// RFC through - the part of it @sap/cds-rfc uses and nothing more: RFCClient, +// open( credentials ) and a connection that executes, commits and closes. +// remote.test.mjs points the module id here (module.registerHooks), so the +// sample's calls run through @sap/cds-rfc exactly as against a system, and +// what arrives here is what the system would have received. +// +// Every call is recorded in globalThis.__rfc for the test to read - of the +// credentials the names and the address, never the password. +const calls = (globalThis.__rfc ??= []); + +const bapiret2 = (TYPE, MESSAGE) => ({ TYPE, ID: "01", NUMBER: "124", MESSAGE }); + +// the answers of an ABAP system, DATS as it sends them: YYYYMMDD +const ANSWERS = { + BAPI_USER_GETLIST: ({ table }) => ({ + ROWS: 1, + SELECTION_RANGE: table.SELECTION_RANGE ?? [], + USERLIST: [{ USERNAME: "DEVELOPER", FIRSTNAME: "", LASTNAME: "Developer", FULLNAME: "Developer" }], + RETURN: [], + }), + BAPI_USER_GET_DETAIL: ({ import: { USERNAME } }) => (USERNAME !== "DEVELOPER" + ? { LOGONDATA: {}, ADDRESS: {}, ISLOCKED: {}, ACTIVITYGROUPS: [], + RETURN: [bapiret2("E", `User ${USERNAME} does not exist`)] } + : { LOGONDATA: { GLTGV: "20240101", GLTGB: "00000000", USTYP: "A", CLASS: "DEVELOPER" }, + ADDRESS: { FIRSTNAME: "", LASTNAME: "Developer", FULLNAME: "Developer", DEPARTMENT: "IT", + FUNCTION: "ABAP Developer", TEL1_NUMBR: "", E_MAIL: "developer@example.com" }, + ISLOCKED: { WRNG_LOGON: "U", LOCAL_LOCK: "U", GLOB_LOCK: "U", NO_USER_PW: "U" }, + ACTIVITYGROUPS: [{ AGR_NAME: "Z_ABAP_DEVELOPER", FROM_DAT: "20240101", TO_DAT: "99991231", + AGR_TEXT: "Develop in ABAP", ORG_FLAG: "" }], + RETURN: [] }), +}; + +class RFCClient { + async open({ ashost, sysnr, client, lang, ...rest }) { + calls.push({ open: { ashost, sysnr, client, lang, also: Object.keys(rest).sort() } }); + return { + async execute(name, params) { + calls.push({ execute: name, params }); + return ANSWERS[name](params); + }, + async commit() { calls.push({ commit: true }); }, + async rollback() { calls.push({ rollback: true }); }, + async close() { calls.push({ close: true }); }, + }; + } +} + +module.exports = { RFCClient }; diff --git a/test/remote.test.mjs b/test/remote.test.mjs new file mode 100644 index 0000000..eaac92f --- /dev/null +++ b/test/remote.test.mjs @@ -0,0 +1,130 @@ +// Both samples through the PROTOCOLS a real system is reached by. Credentials +// are configured, so CAP does not mock the services, and every call leaves +// through the code that would call your system - only the far end is faked: +// +// API_BUSINESS_PARTNER CAP's OData V2 client, to an HTTP server in this +// process that answers as the S/4HANA service does +// SAP_RFC @sap/cds-rfc, to a stand-in for its connector +// @sap-rfc/node-rfc-library (test/fixtures/) +// +// Both far ends record what they receive, so the assertions are about what +// the system would get: the OData request, the RFC parameters by kind. +import assert from "node:assert/strict"; +import http from "node:http"; +import nodeModule from "node:module"; +import path from "node:path"; +import { after, test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { messageBox, post, ROOT, serve, slot } from "./server.mjs"; + +// ---- the S/4HANA end of API_BUSINESS_PARTNER: OData V2 JSON, as Gateway answers +const requests = []; +const s4 = http.createServer((req, res) => { + requests.push({ url: decodeURIComponent(req.url), apikey: req.headers.apikey }); + const set = req.url.split("?")[0].split("/").pop(); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ d: set === "A_BusinessPartner" + ? { __count: "1234", results: [ + { __metadata: { type: "API_BUSINESS_PARTNER.A_BusinessPartnerType" }, + BusinessPartner: "17100001", BusinessPartnerFullName: "Domestic Supplier US 1", + BusinessPartnerCategory: "2", SearchTerm1: "SUPPL_US1", CreationDate: "/Date(1556755200000)/" }] } + : { results: [ + { __metadata: { type: "API_BUSINESS_PARTNER.A_BusinessPartnerAddressType" }, + AddressID: "22820", StreetName: "Industrial Park", HouseNumber: "500", PostalCode: "30303", + CityName: "Atlanta", Country: "US" }] } })); +}); +await new Promise((resolve) => s4.listen(0, "127.0.0.1", resolve)); +after(() => s4.close()); + +// ---- the SAP system end of SAP_RFC: the connector, redirected to the stand-in +if (!nodeModule.registerHooks) throw new Error("remote.test.mjs needs module.registerHooks - Node.js 22.15 or later"); +const CONNECTOR = pathToFileURL(path.join(ROOT, "test/fixtures/node-rfc-library.cjs")).href; +nodeModule.registerHooks({ + resolve: (specifier, context, next) => (specifier === "@sap-rfc/node-rfc-library" + ? { url: CONNECTOR, shortCircuit: true } + : next(specifier, context)), +}); +const rfc = (globalThis.__rfc ??= []); + +// ---- the credentials, as a .env file would give them (cds_requires__credentials) +const S4_URL = `http://127.0.0.1:${s4.address().port}/sap/opu/odata/sap/API_BUSINESS_PARTNER`; +process.env.cds_requires_API__BUSINESS__PARTNER_credentials = JSON.stringify({ url: S4_URL, headers: { APIKey: "my-api-key" } }); +process.env.cds_requires_SAP__RFC_credentials = JSON.stringify( + { ashost: "sap.example.com", sysnr: "00", client: "100", user: "RFC_CAP", passwd: "not-a-real-one" }); + +const s = serve(); +const P = (o) => post(s.url, o); +const ok = (r) => assert.equal(r.status, 200, r.text.slice(0, 500)); + +test("001 OData: the query arrives as an OData V2 request, the answer as rows", async () => { + const APP = "Z2UI5_CL_CAPS_APP_001"; + const start = await P({ app: APP }); + ok(start); + assert.equal(messageBox(start), undefined, messageBox(start)?.text); + assert.equal(start.json.MODEL.SOURCE, S4_URL, "the screen names the service it reached"); + assert.deepEqual(requests.shift(), { + url: "/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner" + + "?$select=BusinessPartner,BusinessPartnerFullName,BusinessPartnerCategory,SearchTerm1,CreationDate" + + "&$orderby=BusinessPartner&$top=20&$inlinecount=allpages", + apikey: "my-api-key" }, "the headers of the credentials go along - the sandbox's APIKey"); + assert.equal(start.json.MODEL.COUNT, 1234, "$inlinecount: every hit, not only the page"); + assert.deepEqual(start.json.MODEL.PARTNERS, [{ + BUSINESSPARTNER: "17100001", BUSINESSPARTNERFULLNAME: "Domestic Supplier US 1", BUSINESSPARTNERCATEGORY: "2", + SEARCHTERM1: "SUPPL_US1", CREATIONDATE: "2019-05-02" }], "an Edm.DateTime arrives as the date it is"); + + const search = await P({ app: APP, id: start.id, event: "SEARCH", model: { SEARCH: "Supplier", CATEGORY: "2" } }); + ok(search); + assert.equal(requests.shift().url, "/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner" + + "?$select=BusinessPartner,BusinessPartnerFullName,BusinessPartnerCategory,SearchTerm1,CreationDate" + + "&$orderby=BusinessPartner&$top=20" + + "&$filter=substringof('Supplier',BusinessPartnerFullName) and BusinessPartnerCategory eq '2'" + + "&$inlinecount=allpages"); + + // a quote in the search is data, not OData syntax + const quoted = await P({ app: APP, id: search.id, event: "SEARCH", model: { SEARCH: "O'Brien", CATEGORY: "" } }); + ok(quoted); + assert.match(requests.shift().url, /&\$filter=substringof\('O''Brien',BusinessPartnerFullName\)&\$inlinecount/); + + const addresses = await P({ app: APP, id: quoted.id, event: "ADDRESSES", args: ["17100001"] }); + ok(addresses); + assert.equal(requests.shift().url, "/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartnerAddress" + + "?$select=AddressID,StreetName,HouseNumber,PostalCode,CityName,Country&$filter=BusinessPartner eq '17100001'"); + assert.equal(addresses.json.MODEL.PARTNER, "Domestic Supplier US 1"); + assert.deepEqual(addresses.json.MODEL.ADDRESSES.map((a) => a.CITYNAME), ["Atlanta"]); + assert.ok(slot(addresses, "POPUP")); +}); + +test("002 RFC: each BAPI reaches the connector with its parameters sorted by kind", async () => { + const APP = "Z2UI5_CL_CAPS_APP_002"; + const start = await P({ app: APP }); + ok(start); + assert.equal(messageBox(start), undefined, messageBox(start)?.text); + assert.equal(start.json.MODEL.SOURCE, "sap.example.com, client 100", "the screen names the system - not the user"); + assert.deepEqual(rfc.splice(0), [ + { open: { ashost: "sap.example.com", sysnr: "00", client: "100", lang: "en", also: ["passwd", "user"] } }, + { execute: "BAPI_USER_GETLIST", params: { + import: { MAX_ROWS: 50, WITH_USERNAME: "X" }, + changing: {}, + table: { SELECTION_RANGE: [{ PARAMETER: "USERNAME", SIGN: "I", OPTION: "CP", LOW: "*" }] } } }, + { commit: true }, + { close: true }, + ], "one connection per call: open, execute, commit, close - as @sap/cds-rfc runs a transaction"); + assert.deepEqual(start.json.MODEL.USERS.map((u) => u.USERNAME), ["DEVELOPER"]); + assert.equal(start.json.MODEL.ROWS, 1); + + const detail = await P({ app: APP, id: start.id, event: "DETAIL", args: ["DEVELOPER"] }); + ok(detail); + assert.deepEqual(rfc.splice(0).find((c) => c.execute), + { execute: "BAPI_USER_GET_DETAIL", params: { import: { USERNAME: "DEVELOPER" }, changing: {}, table: {} } }); + assert.deepEqual(detail.json.MODEL.USER, { + USERNAME: "DEVELOPER", FULLNAME: "Developer", DEPARTMENT: "IT", FUNCTION: "ABAP Developer", + E_MAIL: "developer@example.com", TEL1_NUMBR: "", USTYP: "Dialog", CLASS: "DEVELOPER", + GLTGV: "2024-01-01", GLTGB: "", LOCKED: false }); + assert.deepEqual(detail.json.MODEL.ROLES, + [{ AGR_NAME: "Z_ABAP_DEVELOPER", AGR_TEXT: "Develop in ABAP", FROM_DAT: "2024-01-01", TO_DAT: "9999-12-31" }]); + assert.ok(slot(detail, "POPUP")); + + const nobody = await P({ app: APP, id: detail.id, event: "DETAIL", args: ["NOBODY"] }); + rfc.splice(0); + assert.deepEqual(messageBox(nobody), { type: "error", text: "User NOBODY does not exist" }); +}); diff --git a/test/samples.test.mjs b/test/samples.test.mjs new file mode 100644 index 0000000..02c6813 --- /dev/null +++ b/test/samples.test.mjs @@ -0,0 +1,112 @@ +// Both samples, driven over the wire against CAP's MOCKS of the systems they +// call - what cds watch serves while no credentials are configured: the +// business partner service from srv/external/data/, the SAP system from +// srv/external/SAP_RFC.js. remote.test.mjs drives the same samples through +// the protocols a real system is reached by. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { firedBy, messageBox, post, ROOT, serve, slot, ui5Findings, wellFormed } from "./server.mjs"; + +const s = serve(); +const P = (o) => post(s.url, o); +const ok = (r) => assert.equal(r.status, 200, r.text.slice(0, 500)); + +const APPS = fs.readdirSync(path.join(ROOT, "srv/apps")) + .filter((f) => /^z2ui5_cl_caps_app_\d+\.js$/.test(f)) + .map((f) => f.replace(/\.js$/, "").toUpperCase()); + +test("every sample starts against the mocks, and its view is well-formed XML", async () => { + assert.ok(APPS.length > 0); + for (const app of APPS) { + const r = await P({ app }); + ok(r); + assert.equal(messageBox(r), undefined, `${app}: the mock did not answer - ${messageBox(r)?.text}`); + assert.match(r.json.MODEL.SOURCE, /^the mock of /, `${app}: the screen says who answered`); + const xml = slot(r, "MAIN"); + assert.ok(xml, `${app}: no MAIN view on the start`); + assert.equal(wellFormed(xml), true, `${app}: ${wellFormed(xml)}`); + assert.deepEqual(ui5Findings(xml), [], `${app}: every control and property exists in UI5 1.71`); + assert.match(xml, /title="cap2UI5 - /, `${app}: the page title`); + assert.match(xml, /showNavButton="false"/, `${app}: started directly, there is nothing to go back to`); + assert.ok(firedBy(xml, "navButtonPress"), `${app}: the back button carries the nav-back wire`); + } +}); + +test("001 OData: the first page, a search with two filters, one partner's addresses", async () => { + const APP = "Z2UI5_CL_CAPS_APP_001"; + const start = await P({ app: APP }); + assert.equal(start.json.MODEL.COUNT, 14, "every business partner of srv/external/data/"); + assert.equal(start.json.MODEL.PARTNERS.length, 14); + assert.deepEqual(start.json.MODEL.PARTNERS[0], { + BUSINESSPARTNER: "1000000", BUSINESSPARTNERCATEGORY: "2", BUSINESSPARTNERFULLNAME: "Inlandskunde DE 1", + SEARCHTERM1: "KUNDE_DE1", CREATIONDATE: "2019-02-11" }); + assert.match(slot(start, "MAIN"), /press="\.eB\(\['ADDRESSES'\], \$\{BUSINESSPARTNER\}\)"/, + "a row press sends its business partner"); + + // the user types part of a name and picks a category - both bound, both sent back + const search = await P({ app: APP, id: start.id, event: "SEARCH", model: { SEARCH: "Supplier", CATEGORY: "2" } }); + ok(search); + assert.deepEqual(search.json.MODEL.PARTNERS.map((p) => p.BUSINESSPARTNERFULLNAME), + ["Domestic Supplier US 1", "Domestic Supplier US 2"]); + assert.equal(search.json.MODEL.COUNT, 2); + assert.equal(slot(search, "MAIN"), undefined, "a search pushes the model, it does not rebuild the view"); + + const persons = await P({ app: APP, id: search.id, event: "SEARCH", model: { SEARCH: "", CATEGORY: "1" } }); + assert.deepEqual(persons.json.MODEL.PARTNERS.map((p) => p.BUSINESSPARTNER), + ["1000020", "1000021", "1000022", "1000023"]); + + const addresses = await P({ app: APP, id: persons.id, event: "ADDRESSES", args: ["1000021"] }); + ok(addresses); + const popup = slot(addresses, "POPUP"); + assert.equal(wellFormed(popup), true, wellFormed(popup)); + assert.deepEqual(ui5Findings(popup), []); + assert.match(popup, / { + const APP = "Z2UI5_CL_CAPS_APP_002"; + const start = await P({ app: APP }); + assert.equal(start.json.MODEL.ROWS, 7, "ROWS - every user of SAP_RFC.js"); + assert.deepEqual(start.json.MODEL.USERS[0], + { USERNAME: "ASCHMIDT", FIRSTNAME: "Anna", LASTNAME: "Schmidt", FULLNAME: "Anna Schmidt" }); + assert.match(slot(start, "MAIN"), /press="\.eB\(\['DETAIL'\], \$\{USERNAME\}\)"/, "a row press sends its user"); + + // typed in lower case: the app upper-cases it for the CP range + const search = await P({ app: APP, id: start.id, event: "SEARCH", model: { PATTERN: "*m*" } }); + ok(search); + assert.deepEqual(search.json.MODEL.USERS.map((u) => u.USERNAME), ["ASCHMIDT", "JMILLER", "MGARCIA"]); + assert.equal(search.json.MODEL.ROWS, 3); + + const detail = await P({ app: APP, id: search.id, event: "DETAIL", args: ["JMILLER"] }); + ok(detail); + const popup = slot(detail, "POPUP"); + assert.equal(wellFormed(popup), true, wellFormed(popup)); + assert.deepEqual(ui5Findings(popup), []); + assert.match(popup, / [ + ...(r.json?.S_FRONT?.S_ACTION?.T_SYSTEM ?? []), + ...(r.json?.S_FRONT?.S_ACTION?.T_CUSTOM ?? []), +]; + +/** The XML a response displays into a view slot (MAIN, POPUP, NEST, …), or undefined. */ +export const slot = (r, name) => actions(r) + .find((a) => a[0] === "VIEW_SLOTS" && a[1] === "display" && a[2] === name)?.[3]; + +/** The message box a response opens, as { type, text }, or undefined. */ +export const messageBox = (r) => { + const a = actions(r).find((x) => x[0] === "MESSAGE_BOX"); + return a && { type: a[1], text: a[2] }; +}; + +/** true, or the parser's complaint - a template literal typo shows up here, not only in the browser */ +export const wellFormed = (xml) => { + const v = XMLValidator.validate(xml); + return v === true ? true : `${v.err.code} at ${v.err.line}:${v.err.col} - ${v.err.msg}`; +}; + +/** + * What the abap2UI5 linter's property gate finds in a view: every control, + * property, aggregation and event against the UI5 metadata, at the release + * the abap2UI5 family supports (1.71) - [] when it finds nothing. A property + * that does not exist renders as nothing, silently; this is where it shows. + * (The render gate needs a browser and stays out of npm test.) + */ +export const ui5Findings = (xml) => checkXmlSource(xml, { render: false, minUi5: "1.71" }) + .findings.map((f) => `${f.severity}: ${f.message}`); + +/** the event a handler attribute's wire fires, as the browser sends it back */ +export const firedBy = (xml, attr) => xml.match(new RegExp(`${attr}="\\.eB\\(\\['([^']+)'`))?.[1];