@cap-ts/soap-adapter 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -105
- package/index.cds +0 -38
- package/package.json +13 -3
- package/src/index.js +1 -1
- package/src/lib/_base/index.js +1 -1
- package/src/lib/_builder/index.js +1 -1
- package/src/lib/_client/index.js +1 -1
- package/src/lib/_loader/index.js +1 -1
- package/src/lib/_log/index.js +1 -1
- package/src/lib/_orchestrator/index.js +1 -1
- package/src/lib/_read/index.js +1 -1
- package/src/lib/_request/index.js +1 -1
- package/src/lib/_util/entity.js +1 -1
- package/src/lib/index.js +1 -1
package/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@cap-ts/soap-adapter)
|
|
4
4
|
|
|
5
|
-
> **CAP plugin
|
|
6
|
-
> Node.js ≥
|
|
5
|
+
> **CAP plugin that exposes legacy SOAP web services as read-only, OData-style entities of a `@sap/cds` service.**
|
|
6
|
+
> Node.js ≥ 20, `@sap/cds` ^9.9.1 or ^10 (cds 10 itself requires Node.js ≥ 22).
|
|
7
7
|
|
|
8
8
|
## 📦 About
|
|
9
9
|
|
|
10
|
-
`@cap-ts/soap-adapter` turns a WSDL operation into a CAP entity: consumers declare the entity in `.cds` with a small set of `@Soap.*` annotations, ship an adapter subclass that shapes the SOAP request/response, and the plugin does the rest — CSRF-safe HTTP routing (`/soap/**`), destination + JWT propagation, filter push-down, XML build/parse, response flattening, and
|
|
10
|
+
`@cap-ts/soap-adapter` turns a WSDL operation into a CAP entity: consumers declare the entity in `.cds` with a small set of `@Soap.*` annotations, ship an adapter subclass that shapes the SOAP request/response, and the plugin does the rest — CSRF-safe HTTP routing (`/soap/**`), destination + JWT propagation, filter push-down, XML build/parse, response flattening, and an OData-style JSON response (`@odata.context`, `@odata.count`, `@odata.nextLink`). It is read only: writes are rejected with 405.
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -43,14 +43,15 @@ The package registers itself as a CAP plugin via `cds-plugin` — no explicit `r
|
|
|
43
43
|
|
|
44
44
|
### Peer requirements
|
|
45
45
|
|
|
46
|
-
| Peer |
|
|
46
|
+
| Peer | Version | Notes |
|
|
47
47
|
| --- | --- | --- |
|
|
48
|
-
| `@sap/cds` |
|
|
49
|
-
| Node.js |
|
|
50
|
-
| `@
|
|
51
|
-
| `@sap-cloud-sdk/
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
48
|
+
| `@sap/cds` | `^9.9.1 \|\| ^10` | Tested against 9.9.3 and 10.1.1. |
|
|
49
|
+
| Node.js | >=20 | cds 10 itself requires Node 22 or later. |
|
|
50
|
+
| `@cap-js/cds-types` | >=0.18.0, optional | TypeScript types only; not needed to run the adapter. |
|
|
51
|
+
| `@sap-cloud-sdk/http-client` | ^4.7.0 | Destination-based HTTP calls. |
|
|
52
|
+
| `@sap-cloud-sdk/connectivity` | ^4.7.0 | Destination resolution and JWT forwarding. |
|
|
53
|
+
| `soap` | ^1.10.0 | Dependency, installed automatically. |
|
|
54
|
+
| `p-limit` | ^3.1.0 | Dependency, installed automatically. |
|
|
54
55
|
|
|
55
56
|
Optional but recommended:
|
|
56
57
|
|
|
@@ -99,7 +100,7 @@ Optional but recommended:
|
|
|
99
100
|
└────────────────────────────────────────────────────────────────────────┘
|
|
100
101
|
│
|
|
101
102
|
▼
|
|
102
|
-
|
|
103
|
+
OData-style GET endpoint at `/soap/<service path>/<Entity>`
|
|
103
104
|
```
|
|
104
105
|
|
|
105
106
|
Two-word summary: **annotate + subclass**. The plugin needs both — the annotations tell it *what* to route, the subclass tells it *how* to marshal.
|
|
@@ -175,30 +176,27 @@ class BP extends soap.ApplicationService {
|
|
|
175
176
|
init() {
|
|
176
177
|
const { BusinessPartner } = this.entities;
|
|
177
178
|
|
|
178
|
-
this.header(BusinessPartner, (req) => ({
|
|
179
|
-
// Strip inbound HTTP auth/session/trace before propagating.
|
|
180
|
-
...soap.ApplicationService.stripSensitiveHeaders(req.headers ?? {}),
|
|
181
|
-
'SOAPAction': '"GetBusinessPartner"'
|
|
182
|
-
}));
|
|
183
|
-
|
|
184
179
|
this.request(BusinessPartner, (req, payload) => {
|
|
185
|
-
// Shape the SOAP body from the incoming CAP $filter.
|
|
180
|
+
// Shape the SOAP body (the operation's input message) from the incoming CAP $filter.
|
|
186
181
|
const key = req.query?.SELECT?.where; // CQN WHERE array
|
|
187
182
|
return { BusinessPartnerID: extractBpId(key) };
|
|
188
183
|
});
|
|
189
184
|
|
|
190
185
|
this.response(BusinessPartner, (req, rows) => {
|
|
191
|
-
// Rows
|
|
192
|
-
return rows.
|
|
186
|
+
// Rows are the raw entries under `rootResponse`; `@Soap.path` mapping runs after this hook.
|
|
187
|
+
return rows.filter(r => r != null);
|
|
193
188
|
});
|
|
194
189
|
}
|
|
195
190
|
}
|
|
196
191
|
module.exports = BP;
|
|
197
192
|
```
|
|
198
193
|
|
|
199
|
-
**4. Start `cds watch`.** The loader
|
|
194
|
+
**4. Start `cds watch`.** The loader mounts GET routes under `/soap` + the service's `@path`, or, without `@path`,
|
|
195
|
+
its short name in lower case:
|
|
200
196
|
|
|
201
|
-
- `/soap/
|
|
197
|
+
- `/soap/bp/BusinessPartner` — the entity set (`$select`, `$count`, `$filter`, `$orderby`, `$top`, `$skip`; any other
|
|
198
|
+
system query option is a 400). `@requires` on the service is enforced.
|
|
199
|
+
- `/soap/bp/BusinessPartner/<key>` — a read by key (404 when nothing comes back).
|
|
202
200
|
|
|
203
201
|
---
|
|
204
202
|
|
|
@@ -206,7 +204,7 @@ module.exports = BP;
|
|
|
206
204
|
|
|
207
205
|
[↑ Table of Contents](#-table-of-contents)
|
|
208
206
|
|
|
209
|
-
All
|
|
207
|
+
All seven annotations (`@Soap` and the six `@Soap.*` below) are declared in the package's `index.cds` under the single canonical top-level namespace. `@Soap` (Boolean) only marks a service in the model; what makes a service SOAP-backed at runtime is `kind: "soap"` in `cds.requires`. They are real CDS typed annotations, so `cds compile` type-checks consumer usage — typos and shape mismatches surface at build time, not at first request.
|
|
210
208
|
|
|
211
209
|
### `@Soap.binding` *(struct, entity-level)*
|
|
212
210
|
|
|
@@ -222,8 +220,8 @@ entity BusinessPartner { … }
|
|
|
222
220
|
|
|
223
221
|
| Field | Required | Notes |
|
|
224
222
|
| ----- | -------- | ----- |
|
|
225
|
-
| `rootRequest` |
|
|
226
|
-
| `rootResponse` | No | SOAP response element whose contents become the CAP result. If
|
|
223
|
+
| `rootRequest` | No | Documents the operation's input element. The runtime does not wrap the payload with it: `node-soap` builds the request envelope from the WSDL operation, and the `request()` hook's return value is passed to it as the input message. |
|
|
224
|
+
| `rootResponse` | No | Dot path of the SOAP response element whose contents become the CAP result. If `@Soap.rootResponse` (scalar) is set it takes precedence; without either, `<operation>Response` is used. |
|
|
227
225
|
|
|
228
226
|
### `@Soap.operation` *(scalar, entity-level)* — **required**
|
|
229
227
|
|
|
@@ -238,11 +236,11 @@ An entity without an `@Soap.operation` is invisible to the loader — no route i
|
|
|
238
236
|
|
|
239
237
|
### `@Soap.rootResponse` *(scalar, entity-level)* — optional
|
|
240
238
|
|
|
241
|
-
|
|
239
|
+
Dot path of the SOAP response element to unwrap. When set, it takes precedence over `@Soap.binding.rootResponse`.
|
|
242
240
|
|
|
243
241
|
### `@Soap.path` *(scalar, element-level)*
|
|
244
242
|
|
|
245
|
-
|
|
243
|
+
Dot path, relative to one entry under `rootResponse`, from which the value of this element is read.
|
|
246
244
|
|
|
247
245
|
```cds
|
|
248
246
|
entity BusinessPartner {
|
|
@@ -252,7 +250,7 @@ entity BusinessPartner {
|
|
|
252
250
|
}
|
|
253
251
|
```
|
|
254
252
|
|
|
255
|
-
Without `@Soap.path`, the
|
|
253
|
+
Without `@Soap.path`, the element's own name is used as the path (case-sensitive). `$value` wrappers and `item` / `Item` arrays are unwrapped along the way; a missing value becomes `null`. Only elements with a built-in `cds.*` type are mapped. `@Soap.path` is essential when SOAP responses nest values several levels deep or when element names differ from the response.
|
|
256
254
|
|
|
257
255
|
### `@Soap.filterRestriction` *(struct, entity-level)*
|
|
258
256
|
|
|
@@ -269,7 +267,7 @@ entity BusinessPartner { … }
|
|
|
269
267
|
| Field | Type | Behaviour |
|
|
270
268
|
| ----- | ---- | --------- |
|
|
271
269
|
| `mandatoryFields` | `array of String` | Property names that MUST appear in `$filter` for the request to be accepted. Missing fields raise a 400 with a diagnostic message that names the field. |
|
|
272
|
-
| `multipleSelection` | `Boolean` (default
|
|
270
|
+
| `multipleSelection` | `Boolean` (default: not set) | Set it to `false` when the SOAP operation accepts only one key value per call. The runtime then expands a `$filter` with `<first key> in (a,b,c)` into one SOAP call per value (in batches of `cds.env.soap.multiSelectionConcurrency`, default 5) and merges the results. A second `in(…)` on the key, or `in(…)` combined with `or`, is a 400 (`UNSUPPORTED_MULTI_SELECTION_FILTER`). When `true` or not set, nothing is expanded and the `request()` hook receives the `in(…)` as is. |
|
|
273
271
|
|
|
274
272
|
See § [Filter push-down & `filterRestriction`](#-filter-push-down--filterrestriction) below.
|
|
275
273
|
|
|
@@ -332,18 +330,25 @@ Available inside `init()` via `this.*`:
|
|
|
332
330
|
|
|
333
331
|
### `this.header(entity, fn)` — outbound HTTP headers
|
|
334
332
|
|
|
335
|
-
Registers a hook that
|
|
333
|
+
Registers a hook (or a static object) that adds a **SOAP header** — an element inside the envelope's `<soap:Header>` — to every outbound call for `entity`. It is passed to `node-soap`'s `addSoapHeader`; it does not set HTTP headers.
|
|
336
334
|
|
|
337
335
|
```js
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
336
|
+
// Plain map: each key becomes a header element.
|
|
337
|
+
this.header(BusinessPartner, (req) => ({ TraceID: req.id }));
|
|
338
|
+
|
|
339
|
+
// Structured: value plus element name, namespace prefix and URI.
|
|
340
|
+
this.header(BusinessPartner, () => ({
|
|
341
|
+
value: { Username: 'user', Password: 'secret' },
|
|
342
|
+
name: 'UsernameToken', prefix: 'wsse',
|
|
343
|
+
xmlns: 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd'
|
|
341
344
|
}));
|
|
342
345
|
```
|
|
343
346
|
|
|
344
|
-
- `req` — the
|
|
345
|
-
- Return value: a plain
|
|
346
|
-
-
|
|
347
|
+
- Hook arguments: `(req, payload)` — the CAP `Request` and the outbound payload.
|
|
348
|
+
- Return value: a plain map, or a structured `{ value, name?, prefix?, xmlns? }`. Returning `null` / `undefined` adds nothing.
|
|
349
|
+
- In a plain map, keys on the forbidden list below are dropped (with a warning), so inbound credentials cannot be copied into the envelope by accident.
|
|
350
|
+
- Without any `header()` hook the runtime adds one default SOAP header, `TraceID`, with the request's correlation id.
|
|
351
|
+
- Called once per SOAP dispatch (including each fan-out call for `multipleSelection: false`).
|
|
347
352
|
|
|
348
353
|
### `this.request(entity, fn)` — outbound SOAP payload
|
|
349
354
|
|
|
@@ -357,10 +362,11 @@ this.request(BusinessPartner, (req, payload) => {
|
|
|
357
362
|
});
|
|
358
363
|
```
|
|
359
364
|
|
|
360
|
-
- `req` — incoming CAP `Request`.
|
|
361
|
-
- `payload` —
|
|
362
|
-
- Return value: the
|
|
363
|
-
-
|
|
365
|
+
- `req` — incoming CAP `Request`; read `$filter` / keys from `req.query.SELECT.where`.
|
|
366
|
+
- `payload` — `req.data` (usually `{}` for a READ) for the first hook, else the previous hook's return value.
|
|
367
|
+
- Return value: the operation's input message, passed to `node-soap`, which builds the `<soap:Body>` from the WSDL.
|
|
368
|
+
- Several `request()` hooks run in registration order, each receiving the previous result. Always return the payload:
|
|
369
|
+
a hook that returns `undefined` sends an empty request.
|
|
364
370
|
|
|
365
371
|
### `__filterPushedDown` sentinel
|
|
366
372
|
|
|
@@ -395,13 +401,13 @@ this.mock(BusinessPartner, (req) => {
|
|
|
395
401
|
});
|
|
396
402
|
```
|
|
397
403
|
|
|
398
|
-
- Return value:
|
|
404
|
+
- Return value: the final rows, in the shape of the CAP entity. The mock's result is returned as is: no `response()` hooks, no `@Soap.path` mapping and no in-memory `$filter` / `$orderby` / paging.
|
|
399
405
|
- When a mock is registered the runtime skips destination resolution, WSDL loading, and HTTP dispatch entirely.
|
|
400
|
-
-
|
|
406
|
+
- A registered mock is always active, in every environment — register it only in test or development code.
|
|
401
407
|
|
|
402
408
|
### `this.response(entity, fn)` — inbound result transform
|
|
403
409
|
|
|
404
|
-
Registers a hook that post-processes the result before
|
|
410
|
+
Registers a hook that post-processes the SOAP result before it is mapped to the entity.
|
|
405
411
|
|
|
406
412
|
```js
|
|
407
413
|
this.response(BusinessPartner, (req, rows) => {
|
|
@@ -412,17 +418,20 @@ this.response(BusinessPartner, (req, rows) => {
|
|
|
412
418
|
```
|
|
413
419
|
|
|
414
420
|
- `req` — the incoming CAP `Request`.
|
|
415
|
-
- `rows` —
|
|
416
|
-
|
|
421
|
+
- `rows` — the raw entries under `rootResponse` (always an array). `@Soap.path` mapping and the in-memory
|
|
422
|
+
`$filter` / `$orderby` / paging run **after** this hook, so return entries in the raw response shape.
|
|
423
|
+
- Several `response()` hooks run in registration order, each receiving the previous result. Always return an array:
|
|
424
|
+
a hook that returns `undefined` yields an empty result.
|
|
417
425
|
|
|
418
426
|
### `soap.ApplicationService.stripSensitiveHeaders(headers)` — static helper
|
|
419
427
|
|
|
420
|
-
|
|
428
|
+
Returns a copy of a header map without the entries that must never reach a backend (auth tokens, session cookies, SAP passport, trace/correlation identifiers). Use it when a `header()` hook builds SOAP headers from a map it did not write itself, such as the inbound request headers:
|
|
421
429
|
|
|
422
430
|
```js
|
|
423
431
|
this.header(BusinessPartner, (req) => ({
|
|
424
|
-
|
|
425
|
-
|
|
432
|
+
// Everything from the inbound request except credentials, cookies and trace ids.
|
|
433
|
+
...soap.ApplicationService.stripSensitiveHeaders(req.http?.req?.headers ?? {}),
|
|
434
|
+
TraceID: req.id
|
|
426
435
|
}));
|
|
427
436
|
```
|
|
428
437
|
|
|
@@ -468,13 +477,10 @@ const myForbidden = new Set([
|
|
|
468
477
|
...soap.ApplicationService.FORBIDDEN_SOAP_HEADERS,
|
|
469
478
|
'x-my-internal-token',
|
|
470
479
|
]);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
return clean;
|
|
477
|
-
});
|
|
480
|
+
const withoutForbidden = (headers) =>
|
|
481
|
+
Object.fromEntries(Object.entries(headers).filter(([k]) => !myForbidden.has(k.toLowerCase())));
|
|
482
|
+
|
|
483
|
+
this.header(BusinessPartner, (req) => withoutForbidden(req.http?.req?.headers ?? {}));
|
|
478
484
|
```
|
|
479
485
|
|
|
480
486
|
---
|
|
@@ -533,7 +539,7 @@ const rows = await soap.read(
|
|
|
533
539
|
req, // inbound CAP request (source of user, params, headers)
|
|
534
540
|
req.query, // CQN SELECT query
|
|
535
541
|
{ // optional overrides
|
|
536
|
-
|
|
542
|
+
headers: ...,
|
|
537
543
|
params: ...,
|
|
538
544
|
outerReq: req,
|
|
539
545
|
fallbackEntityDef: ...
|
|
@@ -588,7 +594,7 @@ Returns `any[]` — deduped rows in original order.
|
|
|
588
594
|
|
|
589
595
|
### `soap.isSoapService(serviceName)` — check if a service is SOAP-backed
|
|
590
596
|
|
|
591
|
-
Returns `true` when the named service is configured as a SOAP service — either `cds.requires[name].kind === 'soap'` or the CSN definition carries the `@soap` annotation. Safe to call before `cds.services` is populated; relies only on `cds.requires` and `cds.model.definitions`.
|
|
597
|
+
Returns `true` when the named service is configured as a SOAP service — either `cds.requires[name].kind === 'soap'` or the CSN definition carries the legacy lower-case `@soap` annotation (the current `@Soap` annotation is not checked here). Safe to call before `cds.services` is populated; relies only on `cds.requires` and `cds.model.definitions`.
|
|
592
598
|
|
|
593
599
|
```js
|
|
594
600
|
if (soap.isSoapService('BP')) {
|
|
@@ -631,9 +637,9 @@ Global runtime knobs live under `cds.env.soap` (i.e., the `cds.soap` key in `pac
|
|
|
631
637
|
{
|
|
632
638
|
"cds": {
|
|
633
639
|
"soap": {
|
|
634
|
-
"maxConcurrent": 50,
|
|
635
640
|
"pushFilters": true,
|
|
636
641
|
"pageSize": 1000,
|
|
642
|
+
"multiSelectionConcurrency": 5,
|
|
637
643
|
"idempotency": {
|
|
638
644
|
"enabled": false,
|
|
639
645
|
"ttl": 300000,
|
|
@@ -646,10 +652,11 @@ Global runtime knobs live under `cds.env.soap` (i.e., the `cds.soap` key in `pac
|
|
|
646
652
|
|
|
647
653
|
| Key | Type | Default | Description |
|
|
648
654
|
| --- | ---- | ------- | ----------- |
|
|
649
|
-
| `soap.
|
|
655
|
+
| `soap.multiSelectionConcurrency` | `number` | `5` | Batch size for the fan-out of `multipleSelection: false` entities: that many SOAP calls run in parallel, batch after batch. |
|
|
656
|
+
| `soap.maxConcurrent` | `number` | `50` | Reserved. A per-destination `p-limit` pool is prepared from it, but the request path does not use it yet. |
|
|
650
657
|
| `soap.pushFilters` | `boolean` | `true` | Enables the [`__filterPushedDown` sentinel](#__filterpusheddown-sentinel). Set to `false` to force the in-memory `$filter` / `$orderby` / `$top` / `$skip` evaluator even when a `request()` hook sets the sentinel — useful for A/B testing or when a push-down implementation is suspected of misbehaving. |
|
|
651
658
|
| `soap.pageSize` | `number` | `1000` | Default page size for the bounded in-memory fallback when the adapter does **not** set the `__filterPushedDown` sentinel (or when `pushFilters: false`). When the filtered result exceeds one page, the runtime emits an `@odata.nextLink` header and logs a one-time-per-service warning. Hard-capped at `5000` regardless of override. |
|
|
652
|
-
| `soap.idempotency.enabled` | `boolean` | `false` |
|
|
659
|
+
| `soap.idempotency.enabled` | `boolean` | `false` | Reserved for write-handler support, which is not shipped: the adapter is read only, so this has no effect today. |
|
|
653
660
|
| `soap.idempotency.ttl` | `number` (ms) | `300000` | Cache TTL for idempotency keys (5 minutes). |
|
|
654
661
|
| `soap.idempotency.maxEntries` | `number` | `1000` | LRU eviction threshold for the idempotency cache. |
|
|
655
662
|
|
|
@@ -659,9 +666,8 @@ Per-service configuration lives in `cds.requires.<ServiceName>`:
|
|
|
659
666
|
| --- | ---- | -------- | ----------- |
|
|
660
667
|
| `kind` | `"soap"` | **Yes** | Marks the service for SOAP handling. |
|
|
661
668
|
| `wsdl` | `string` | **Yes** | Path to the WSDL file, relative to `cds.root`. |
|
|
662
|
-
| `credentials.destination` | `string` | Yes
|
|
663
|
-
| `credentials.
|
|
664
|
-
| `credentials.username` / `password` | `string` | No | Basic-auth credentials. Only used when `url` is set (not for BTP destinations — those carry credentials in the destination itself). |
|
|
669
|
+
| `credentials.destination` | `string` | **Yes** | Destination name, resolved via `@sap-cloud-sdk/connectivity` (BTP destination service, or the `destinations` environment variable locally). Without it the service fails with `No 'credentials.destination' configured`. |
|
|
670
|
+
| `credentials.path` | `string` | No | Path appended to the destination URL to form the SOAP endpoint. |
|
|
665
671
|
|
|
666
672
|
---
|
|
667
673
|
|
|
@@ -674,9 +680,9 @@ In a multi-tenant SaaS application each subscriber has its own BTP destination.
|
|
|
674
680
|
### How it works
|
|
675
681
|
|
|
676
682
|
1. The CAP runtime validates the incoming JWT and attaches the tenant context to `req`.
|
|
677
|
-
2. On each SOAP dispatch the adapter calls `@sap-cloud-sdk/connectivity`'s `getDestination()` with the
|
|
678
|
-
3. The destination service returns the tenant-specific endpoint URL and credentials.
|
|
679
|
-
4. The
|
|
683
|
+
2. On each SOAP dispatch the adapter calls `@sap-cloud-sdk/connectivity`'s `getDestination()` with the caller's JWT (from the request's token info, or the `Authorization: Bearer` header) and tenant.
|
|
684
|
+
3. The destination service returns the tenant-specific endpoint URL and credentials; the HTTP call goes through `@sap-cloud-sdk/http-client`, so the destination's authentication type (principal propagation, OAuth2, basic, …) applies.
|
|
685
|
+
4. The SOAP client is built from the local WSDL file and cached per service, destination, endpoint URL and tenant.
|
|
680
686
|
|
|
681
687
|
### Configuration
|
|
682
688
|
|
|
@@ -700,37 +706,21 @@ The destination name is the **provider** destination registered in BTP. For mult
|
|
|
700
706
|
|
|
701
707
|
### Local development without a BTP destination
|
|
702
708
|
|
|
703
|
-
|
|
709
|
+
Keep `credentials.destination` and define that destination locally through the Cloud SDK's `destinations`
|
|
710
|
+
environment variable (for example in a git-ignored `.env` or `default-env.json`):
|
|
704
711
|
|
|
705
|
-
```
|
|
706
|
-
{
|
|
707
|
-
"cds": {
|
|
708
|
-
"requires": {
|
|
709
|
-
"BP": {
|
|
710
|
-
"credentials": {
|
|
711
|
-
"url": "http://localhost:8080/soap/BP",
|
|
712
|
-
"username": "alice",
|
|
713
|
-
"password": "Alice@Cap#"
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
712
|
+
```bash
|
|
713
|
+
destinations='[{"name":"DEST_BP","url":"http://localhost:8080","username":"alice","password":"<password>"}]' cds watch
|
|
719
714
|
```
|
|
720
715
|
|
|
721
|
-
> **Security note.** Never commit
|
|
716
|
+
> **Security note.** Never commit destination credentials to source control.
|
|
722
717
|
|
|
723
718
|
### JWT forwarding details
|
|
724
719
|
|
|
725
|
-
The
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
...soap.ApplicationService.stripSensitiveHeaders(req.headers ?? {}),
|
|
730
|
-
// No Authorization key → runtime does NOT forward the inbound JWT.
|
|
731
|
-
'SOAPAction': '"GetBusinessPartner"'
|
|
732
|
-
}));
|
|
733
|
-
```
|
|
720
|
+
The caller's JWT is used for the **destination lookup** only. What the backend receives is decided by the
|
|
721
|
+
destination: with principal propagation or OAuth2 user token exchange the Cloud SDK derives the outbound credentials
|
|
722
|
+
from it; with basic authentication or client credentials the destination's own credentials are used. The inbound
|
|
723
|
+
`Authorization` header is never copied into the SOAP call, and a `header()` hook cannot set HTTP headers.
|
|
734
724
|
|
|
735
725
|
---
|
|
736
726
|
|
|
@@ -756,10 +746,9 @@ See [`__filterPushedDown` sentinel](#__filterpusheddown-sentinel) for the full c
|
|
|
756
746
|
**Path B — Bounded in-memory fallback.**
|
|
757
747
|
|
|
758
748
|
1. The CAP OData layer parses `$filter` into a CQN `WHERE` array.
|
|
759
|
-
2. The
|
|
760
|
-
3.
|
|
761
|
-
4.
|
|
762
|
-
5. The SOAP result is filtered / sorted / paginated **in Node memory** by the in-memory evaluator, bounded by `cds.env.soap.pageSize` (default `1000`, capped at `5000`). When the filtered set exceeds one page, an `@odata.nextLink` is emitted so the client can paginate, and a **one-time-per-service** `in-memory pagination fallback truncated …` warning is logged suggesting migration to Path A.
|
|
749
|
+
2. The `request()` hook reads what it needs from `req.query.SELECT.where` and builds the payload.
|
|
750
|
+
3. For `multipleSelection: false` entities with `<first key> in (…)`, the runtime fans out into one SOAP call per value (batches of `multiSelectionConcurrency`) and merges the results.
|
|
751
|
+
4. The SOAP result is filtered / sorted / paginated **in Node memory** by the in-memory evaluator, bounded by `cds.env.soap.pageSize` (default `1000`, capped at `5000`). When the filtered set exceeds one page, an `@odata.nextLink` is emitted so the client can paginate, and a **one-time-per-service** `in-memory pagination fallback truncated …` warning is logged suggesting migration to Path A.
|
|
763
752
|
|
|
764
753
|
### `@Soap.filterRestriction` reference
|
|
765
754
|
|
|
@@ -782,13 +771,15 @@ GET /soap/FI/e_FinancialDocument?$filter=FiscalYear eq '2025'
|
|
|
782
771
|
|
|
783
772
|
| Value | `$filter` clause | Runtime behaviour |
|
|
784
773
|
| ----- | ---------------- | ----------------- |
|
|
785
|
-
| `
|
|
786
|
-
| `false` | `BusinessPartnerID in ('BP001'
|
|
774
|
+
| `false` | `BusinessPartnerID in ('BP001','BP002')` | Expands to 2 SOAP calls (one per value, run in parallel); results merged. |
|
|
775
|
+
| `false` | `BusinessPartnerID in ('BP001') or Name eq 'x'`, or two `in(…)` on the key | Rejected with HTTP 400 `UNSUPPORTED_MULTI_SELECTION_FILTER`. |
|
|
787
776
|
| `false` | `BusinessPartnerID eq 'BP001'` | Single SOAP call. |
|
|
777
|
+
| `true` / not set | any | Single SOAP call; the `request()` hook handles `in(…)` itself (or the in-memory evaluator filters the result). |
|
|
788
778
|
|
|
789
|
-
###
|
|
779
|
+
### `$expand`
|
|
790
780
|
|
|
791
|
-
|
|
781
|
+
The `/soap/**` routes do not accept `$expand` (it is a 400). To combine SOAP entities with others, read them through
|
|
782
|
+
another service, for example `@cap-ts/remote-service-adapter`, which expands associations across backends.
|
|
792
783
|
|
|
793
784
|
---
|
|
794
785
|
|
|
@@ -798,10 +789,10 @@ When an entity is used as a value-help target (via `$expand` from a parent entit
|
|
|
798
789
|
|
|
799
790
|
| Guarantee | Detail |
|
|
800
791
|
| --- | --- |
|
|
801
|
-
| **No credential leakage** |
|
|
792
|
+
| **No credential leakage** | Inbound HTTP headers are never forwarded. Keys on the forbidden list (auth, cookie, SAP Passport, trace headers) are dropped from plain-map `header()` results, and `stripSensitiveHeaders()` removes them from any map you build. |
|
|
802
793
|
| **JWT tenant isolation** | Destination resolution uses the subscriber JWT; a tenant cannot access another tenant's destination. |
|
|
803
794
|
| **Filter injection prevention** | The filter extractor operates on the parsed CQN tree (not raw `$filter` string), so SQL/SOAP injection via `$filter` is structurally impossible. |
|
|
804
|
-
| **No
|
|
795
|
+
| **No client sharing across tenants** | The SOAP client cache is keyed by service, destination name, endpoint URL and tenant. |
|
|
805
796
|
| **`@requires` enforcement** | All routes mounted under `/soap/**` inherit the CAP `@requires` annotation from the service definition. Unauthenticated requests are rejected by the CAP runtime before reaching the adapter. |
|
|
806
797
|
|
|
807
798
|
### Middleware ordering for consumers
|
|
@@ -872,13 +863,15 @@ cds.on('bootstrap', (app: any) => {
|
|
|
872
863
|
|
|
873
864
|
### Entity not found / `501 Not Implemented`
|
|
874
865
|
|
|
875
|
-
**Symptom:** `GET /soap/
|
|
866
|
+
**Symptom:** `GET /soap/bp/BusinessPartner` returns `404` (no route) or `501` (no adapter class).
|
|
876
867
|
|
|
877
|
-
**Cause:** The loader skips entities without `@Soap.operation
|
|
868
|
+
**Cause:** The loader skips entities without `@Soap.operation`, and a request fails with 501 when no adapter class was found for the entity. Check that:
|
|
878
869
|
|
|
879
870
|
1. The entity has `@Soap.operation: '<WsdlOperationName>'` in its `.cds` model.
|
|
880
871
|
2. The service is declared with `kind: "soap"` in `cds.requires`.
|
|
881
872
|
3. The `.cds` file is reachable from `cds.model` (i.e., it is `using`-imported somewhere in the model graph).
|
|
873
|
+
4. The URL uses the service's `@path`, or its short name in lower case.
|
|
874
|
+
5. The adapter class resolves (see [Adapter class not loaded](#adapter-class-not-loaded--typeerror--is-not-a-constructor)).
|
|
882
875
|
|
|
883
876
|
---
|
|
884
877
|
|
|
@@ -888,7 +881,7 @@ cds.on('bootstrap', (app: any) => {
|
|
|
888
881
|
|
|
889
882
|
**Likely causes:**
|
|
890
883
|
|
|
891
|
-
1. **Wrong `rootResponse`** — the
|
|
884
|
+
1. **Wrong `rootResponse`** — the path in `@Soap.rootResponse` / `@Soap.binding.rootResponse` (default `<operation>Response`) does not match the actual response. Log the raw rows in a `response()` hook to see the shape.
|
|
892
885
|
2. **Missing `@Soap.path`** — the orchestrator cannot locate the value for an element by name. Add explicit `@Soap.path` annotations.
|
|
893
886
|
3. **Filter not pushed down** — the `request()` hook is not reading the filter fields correctly. Log `payload` inside the hook to inspect.
|
|
894
887
|
|
|
@@ -936,7 +929,7 @@ Set the `DEBUG` environment variable before starting `cds watch`:
|
|
|
936
929
|
DEBUG=soap cds watch
|
|
937
930
|
```
|
|
938
931
|
|
|
939
|
-
This enables
|
|
932
|
+
This enables the `soap` logger's debug output. Payloads that are logged are redacted. It does not log the raw SOAP XML; use a `response()` hook to inspect raw rows.
|
|
940
933
|
|
|
941
934
|
---
|
|
942
935
|
|
package/index.cds
CHANGED
|
@@ -1,41 +1,3 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// @cap-ts/soap-adapter — CDS annotation surface
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// A single, dedicated `Soap` namespace declares every runtime annotation
|
|
5
|
-
// this package consumes. Consumers set them with the fully-qualified form:
|
|
6
|
-
//
|
|
7
|
-
// annotate MyService.MyEntity with @Soap.binding: {
|
|
8
|
-
// rootRequest : 'GetBusinessPartnerRequest',
|
|
9
|
-
// rootResponse : 'GetBusinessPartnerResponse'
|
|
10
|
-
// };
|
|
11
|
-
// annotate MyService.MyEntity with @Soap.operation: 'GetBusinessPartner';
|
|
12
|
-
//
|
|
13
|
-
// Design notes.
|
|
14
|
-
// - PascalCase root (`Soap`) matches SAP's own annotation conventions
|
|
15
|
-
// (`@Common.*`, `@UI.*`, `@Capabilities.*`, `@Analytics.*`, `@Core.*`,
|
|
16
|
-
// `@ODM.*`) so this package's annotations sit alongside them naturally
|
|
17
|
-
// in consumer `.cds` files.
|
|
18
|
-
// - Single top-level namespace, no scoping prefix. The domain name
|
|
19
|
-
// `Soap` is specific enough that no other SAP/CAP package will
|
|
20
|
-
// realistically collide with it. Earlier iterations shipped
|
|
21
|
-
// `@soap.*` (deprecated form — collision risk against any other
|
|
22
|
-
// package's top-level `soap` namespace) and `@cap.soap.*` (dual-key
|
|
23
|
-
// transitional form). Both are gone: consumers have been informed of
|
|
24
|
-
// the hard-cutover migration and the runtime reads `@Soap.<name>`
|
|
25
|
-
// directly, with no legacy fallback.
|
|
26
|
-
// - Declared as real typed annotations so `cds compile` type-checks
|
|
27
|
-
// consumer usage — typos and shape mismatches surface at build time,
|
|
28
|
-
// not at first request.
|
|
29
|
-
// - Declared at the top level with the fully-qualified name
|
|
30
|
-
// `Soap.<x>`, matching the shape of SAP's own annotation packages
|
|
31
|
-
// (`@sap/cds/common.cds` for `@Common.*`, `@sap/cds/vocabularies/UI`
|
|
32
|
-
// for `@UI.*`). A `namespace Soap;` prefix would produce identical
|
|
33
|
-
// CSN keys under `@sap/cds-compiler` v7 (the compiler collapses
|
|
34
|
-
// `Soap.Soap.<x>` back to `Soap.<x>` when the annotation name
|
|
35
|
-
// starts with the namespace segment), so both forms are equivalent —
|
|
36
|
-
// but form (a) reads more clearly and requires no `namespace` line.
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
|
|
39
1
|
annotation Soap : Boolean default true;
|
|
40
2
|
|
|
41
3
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-ts/soap-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "SOAP Adapter for CAP",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
|
@@ -30,8 +30,18 @@
|
|
|
30
30
|
"soap": "^1.10.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@cap-js/cds-types": "
|
|
34
|
-
"@sap/cds": "^9.9.1"
|
|
33
|
+
"@cap-js/cds-types": ">=0.18.0",
|
|
34
|
+
"@sap/cds": "^9.9.1 || ^10",
|
|
35
|
+
"@sap-cloud-sdk/connectivity": "^4.7.0",
|
|
36
|
+
"@sap-cloud-sdk/http-client": "^4.7.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"@cap-js/cds-types": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
35
45
|
},
|
|
36
46
|
"files": [
|
|
37
47
|
"index.cds",
|
package/src/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
var _0x1de264=_0xb7f6;function _0xb7f6(_0x3315f1,_0x101f9f){_0x3315f1=_0x3315f1-0x17a;var _0x10dcc0=_0x10dc();var _0xb7f698=_0x10dcc0[_0x3315f1];if(_0xb7f6['byluFQ']===undefined){var _0x10fb9c=function(_0x2ccb9a){var _0x4efddd='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';var _0x1fef4='',_0x477330='';for(var _0x585438=0x0,_0xe748d4,_0x4e0fc7,_0x54d3c6=0x0;_0x4e0fc7=_0x2ccb9a['charAt'](_0x54d3c6++);~_0x4e0fc7&&(_0xe748d4=_0x585438%0x4?_0xe748d4*0x40+_0x4e0fc7:_0x4e0fc7,_0x585438++%0x4)?_0x1fef4+=String['fromCharCode'](0xff&_0xe748d4>>(-0x2*_0x585438&0x6)):0x0){_0x4e0fc7=_0x4efddd['indexOf'](_0x4e0fc7);}for(var _0x3d037e=0x0,_0x3a9fd5=_0x1fef4['length'];_0x3d037e<_0x3a9fd5;_0x3d037e++){_0x477330+='%'+('00'+_0x1fef4['charCodeAt'](_0x3d037e)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x477330);};var _0x5735f0=function(_0x38204f,_0x56e177){var _0x4c5d98=[],_0x4d337f=0x0,_0x16b927,_0x1234ff='';_0x38204f=_0x10fb9c(_0x38204f);var _0x4bf3b7;for(_0x4bf3b7=0x0;_0x4bf3b7<0x100;_0x4bf3b7++){_0x4c5d98[_0x4bf3b7]=_0x4bf3b7;}for(_0x4bf3b7=0x0;_0x4bf3b7<0x100;_0x4bf3b7++){_0x4d337f=(_0x4d337f+_0x4c5d98[_0x4bf3b7]+_0x56e177['charCodeAt'](_0x4bf3b7%_0x56e177['length']))%0x100,_0x16b927=_0x4c5d98[_0x4bf3b7],_0x4c5d98[_0x4bf3b7]=_0x4c5d98[_0x4d337f],_0x4c5d98[_0x4d337f]=_0x16b927;}_0x4bf3b7=0x0,_0x4d337f=0x0;for(var _0x34990a=0x0;_0x34990a<_0x38204f['length'];_0x34990a++){_0x4bf3b7=(_0x4bf3b7+0x1)%0x100,_0x4d337f=(_0x4d337f+_0x4c5d98[_0x4bf3b7])%0x100,_0x16b927=_0x4c5d98[_0x4bf3b7],_0x4c5d98[_0x4bf3b7]=_0x4c5d98[_0x4d337f],_0x4c5d98[_0x4d337f]=_0x16b927,_0x1234ff+=String['fromCharCode'](_0x38204f['charCodeAt'](_0x34990a)^_0x4c5d98[(_0x4c5d98[_0x4bf3b7]+_0x4c5d98[_0x4d337f])%0x100]);}return _0x1234ff;};_0xb7f6['OOjDhB']=_0x5735f0,_0xb7f6['kzoluJ']={},_0xb7f6['byluFQ']=!![];}var _0x593df5=_0x10dcc0[0x0];_0xb7f6['CrSMmE']!==_0x593df5&&(_0xb7f6['kzoluJ']={},_0xb7f6['CrSMmE']=_0x593df5);var _0x94f6a1=_0xb7f6['kzoluJ'][_0x3315f1];return _0x94f6a1===undefined?(_0xb7f6['cqerVd']===undefined&&(_0xb7f6['cqerVd']=!![]),_0xb7f698=_0xb7f6['OOjDhB'](_0xb7f698,_0x101f9f),_0xb7f6['kzoluJ'][_0x3315f1]=_0xb7f698):_0xb7f698=_0x94f6a1,_0xb7f698;}(function(_0x2fbaf8,_0x3ad7e2){var _0x13d8ca=_0xb7f6,_0x1ebd9c=_0x2fbaf8();while(!![]){try{var _0x542c15=parseInt(_0x13d8ca(0x181,'eIiL'))/0x1*(parseInt(_0x13d8ca(0x180,'40zJ'))/0x2)+parseInt(_0x13d8ca(0x192,'rRJ5'))/0x3*(parseInt(_0x13d8ca(0x187,'cZ@m'))/0x4)+-parseInt(_0x13d8ca(0x190,'RMB)'))/0x5*(parseInt(_0x13d8ca(0x18a,'TV&b'))/0x6)+parseInt(_0x13d8ca(0x17f,'tKj0'))/0x7+parseInt(_0x13d8ca(0x182,'Yh9v'))/0x8*(parseInt(_0x13d8ca(0x18d,'TbSF'))/0x9)+parseInt(_0x13d8ca(0x18e,'Yh9v'))/0xa+parseInt(_0x13d8ca(0x18b,'nYqX'))/0xb*(-parseInt(_0x13d8ca(0x191,'TbSF'))/0xc);if(_0x542c15===_0x3ad7e2)break;else _0x1ebd9c['push'](_0x1ebd9c['shift']());}catch(_0x271cdd){_0x1ebd9c['push'](_0x1ebd9c['shift']());}}}(_0x10dc,0x18adf),(Object[_0x1de264(0x189,'Vu$I')](exports,'__esModule',{'value':!0x0}),exports['soap']=void 0x0,exports[_0x1de264(0x184,'TbSF')]=require(_0x1de264(0x193,'^hh#'))));function _0x10dc(){var _0xc0b4a7=['W4FcOSoZW43dIGtdJmkUW4mLwJunyG','x8oummo0p2JdNmoeWPPd','W4G1nmo7pCkXCCobESoUErq','W7XeyZNcQmoJkSkreSkcxgy','aSoZWRpcMNT0W5ZdJeZcR8o0uSk4','W5pcOrBcOvH2j1tcO8ogW6VcRW','WPBdNSowW7idvCkAxs4P','WQCwW7brAMD8o1Po','bCo3W5xdNJGhWOFdRq','W71fyJRdR8kAoCkLpCko','WPlcH8owW6Pu','WONcM1JdHXGDWQ3dRCkAtG','W4rWW6nAoGrrFMS','mmkZWOldHGdcOSoqoGdcUCogaa','cxCTrfmKWR1IkmoEb8kqpG','W5frW7C1W7NdR8oxkW1cWOmA','tCoJASo4WPH1aCoAWQXlBGi','fmouemo8bmk1WPpcV8kkWRWqaq','WPpcMmk6WOOhBSk8','W57dR2/dHsKNoa','WPn/zXWjngtcLdZcOW','qmkOW6pdNq','hCoAAqBcKmkkgq','dmodW6dcQbxdOhm','WO7cMCkFWRu4W48oW6nJ','W7tcO0LtWOFdI8o9bhBcLmoOfW'];_0x10dc=function(){return _0xc0b4a7;};return _0x10dc();}
|