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];