@cap-ts/soap-adapter 0.1.5 → 0.1.8
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 +910 -21
- package/index.cds +96 -5
- package/package.json +24 -23
- 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 -0
- package/src/lib/_orchestrator/index.js +1 -1
- package/src/lib/_read/index.js +1 -0
- package/src/lib/_request/index.js +1 -0
- package/src/lib/_util/entity.js +1 -0
- package/src/lib/index.js +1 -1
- package/types/index.d.ts +29 -4
- package/types/index.d.ts.map +1 -1
- package/types/lib/index.d.ts +399 -81
- package/types/lib/index.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -1,52 +1,941 @@
|
|
|
1
|
+
# @cap-ts/soap-adapter
|
|
1
2
|
|
|
2
3
|
[](https://www.npmjs.com/package/@cap-ts/soap-adapter)
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
**SOAP Adapter for SAP CAP Applications (Node.js)**
|
|
5
|
+
> **CAP plugin for SOAP Adapter that lets `@sap/cds` services expose OData v4 entities backed by legacy SOAP web services.**
|
|
6
|
+
> Node.js ≥ 18, `@sap/cds` v7 / v8 / v9.
|
|
7
7
|
|
|
8
8
|
## 📦 About
|
|
9
9
|
|
|
10
|
-
`@cap-ts/soap-adapter`
|
|
11
|
-
|
|
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 CAP OData v4 exposure.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 📑 Table of Contents
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
📥 [Installation](#-installation)\
|
|
17
|
+
📖 [Usage Guidelines](#-usage-guidelines)\
|
|
18
|
+
⚡ [Quick start (5 minutes)](#-quick-start-5-minutes)\
|
|
19
|
+
📝 [CDS annotation reference (`@Soap.*`)](#-cds-annotation-reference-soap)\
|
|
20
|
+
🔌 [Adapter class API (`soap.ApplicationService`)](#-adapter-class-api-soapapplicationservice)\
|
|
21
|
+
🧰 [Utility API (`soap.*`)](#-utility-api-soap)\
|
|
22
|
+
⚙️ [Configuration (`cds.env.soap.*`)](#️-configuration-cdsenvsoap)\
|
|
23
|
+
🏢 [Multi-tenant destinations & JWT propagation](#-multi-tenant-destinations--jwt-propagation)\
|
|
24
|
+
🔍 [Filter push-down & `filterRestriction`](#-filter-push-down--filterrestriction)\
|
|
25
|
+
🔒 [Security guarantees](#-security-guarantees)\
|
|
26
|
+
🛠️ [Troubleshooting](#️-troubleshooting)\
|
|
27
|
+
💬 [Support & feedback](#-support--feedback)\
|
|
28
|
+
📄 [License](#-license)
|
|
14
29
|
|
|
15
|
-
|
|
30
|
+
---
|
|
16
31
|
|
|
17
|
-
##
|
|
32
|
+
## 📥 Installation
|
|
33
|
+
|
|
34
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
18
35
|
|
|
19
36
|
The package is available on npm and can be installed as follows:
|
|
20
37
|
|
|
21
38
|
```bash
|
|
22
|
-
npm install @cap-ts/soap-adapter
|
|
39
|
+
npm install @cap-ts/soap-adapter
|
|
23
40
|
```
|
|
24
41
|
|
|
25
|
-
|
|
42
|
+
The package registers itself as a CAP plugin via `cds-plugin` — no explicit `require()` is needed. `cds watch` and `cds build` pick it up automatically.
|
|
43
|
+
|
|
44
|
+
### Peer requirements
|
|
45
|
+
|
|
46
|
+
| Peer | Minimum | Notes |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `@sap/cds` | 7.0 | Tested against 7, 8, 9. |
|
|
49
|
+
| Node.js | 18 | Uses `crypto.randomUUID`, `structuredClone`, top-level `require('node:test')` for tests. |
|
|
50
|
+
| `@sap-cloud-sdk/http-client` | 4.7 | Auto-installed. |
|
|
51
|
+
| `@sap-cloud-sdk/connectivity` | 4.7 | Auto-installed. Provides destination resolution + JWT forwarding. |
|
|
52
|
+
| `soap` | 1.10 | Auto-installed. |
|
|
53
|
+
| `p-limit` | 6.2 | Auto-installed. |
|
|
54
|
+
|
|
55
|
+
Optional but recommended:
|
|
56
|
+
|
|
57
|
+
| Dep | When | Notes |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `ts-node` | You author adapter classes in TypeScript | The loader registers `ts-node/transpile-only` on demand and logs a `warn` if it's missing. |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 📖 Usage Guidelines
|
|
64
|
+
|
|
65
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
66
|
+
|
|
67
|
+
```diagram
|
|
68
|
+
┌────────────────────────────────────────────────────────────────────────┐
|
|
69
|
+
│ Consumer BP.cds │
|
|
70
|
+
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
|
71
|
+
│ │ using from '@cap-ts/soap-adapter'; │ │
|
|
72
|
+
│ │ service BP { │ │
|
|
73
|
+
│ │ @Soap.operation: 'GetBusinessPartner' │ │
|
|
74
|
+
│ │ @Soap.binding: { │ │
|
|
75
|
+
| | rootRequest: 'GetBusinessPartnerRequest', │ │
|
|
76
|
+
│ │ rootResponse: 'GetBusinessPartnerResponse' │ │
|
|
77
|
+
| | } │ │
|
|
78
|
+
│ │ entity BusinessPartner { | |
|
|
79
|
+
| | key BusinessPartnerID : String; | |
|
|
80
|
+
| | ... | |
|
|
81
|
+
| | }; │ │
|
|
82
|
+
│ │ } │ │
|
|
83
|
+
│ └──────────────────────────────────────────────────────────────────┘ │
|
|
84
|
+
│ │ │
|
|
85
|
+
│ ▼ │
|
|
86
|
+
│ Consumer srv/lib/BP.js (extends soap.ApplicationService) │
|
|
87
|
+
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
|
88
|
+
│ │ init() { │ │
|
|
89
|
+
│ │ this.header(BusinessPartner, req => ({ Authorization: … })); │ │
|
|
90
|
+
│ │ this.request(BusinessPartner, (req, payload) => …); │ │
|
|
91
|
+
│ │ this.response(BusinessPartner, (req, rows) => rows.map(…)); │ │
|
|
92
|
+
│ │ } │ │
|
|
93
|
+
│ └──────────────────────────────────────────────────────────────────┘ │
|
|
94
|
+
└────────────────────────────────────────────────────────────────────────┘
|
|
95
|
+
│
|
|
96
|
+
▼ at boot: `cds.on('served')`
|
|
97
|
+
┌────────────────────────────────────────────────────────────────────────┐
|
|
98
|
+
│ @cap-ts/soap-adapter runtime │
|
|
99
|
+
└────────────────────────────────────────────────────────────────────────┘
|
|
100
|
+
│
|
|
101
|
+
▼
|
|
102
|
+
CAP OData v4 endpoint at `/soap/<path/service>/**`
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Two-word summary: **annotate + subclass**. The plugin needs both — the annotations tell it *what* to route, the subclass tells it *how* to marshal.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## ⚡ Quick start (5 minutes)
|
|
110
|
+
|
|
111
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
26
112
|
|
|
27
|
-
|
|
113
|
+
Assume a CAP project laid out like:
|
|
114
|
+
|
|
115
|
+
```diagram
|
|
116
|
+
srv/
|
|
117
|
+
external/
|
|
118
|
+
BP.cds ← SOAP service model
|
|
119
|
+
lib/
|
|
120
|
+
BP.js ← adapter subclass (of soap.ApplicationService)
|
|
121
|
+
wsdl/
|
|
122
|
+
BP.wsdl ← WSDL cached locally
|
|
123
|
+
package.json
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**1. Configure the SOAP service in `package.json`.**
|
|
28
127
|
|
|
29
128
|
```json
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
129
|
+
{
|
|
130
|
+
"cds": {
|
|
131
|
+
"requires": {
|
|
132
|
+
"BP": {
|
|
133
|
+
"kind": "soap",
|
|
134
|
+
"wsdl": "srv/external/wsdl/BP.wsdl",
|
|
135
|
+
"credentials": { "destination": "DEST_BP" }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
35
138
|
}
|
|
36
139
|
}
|
|
37
140
|
```
|
|
38
141
|
|
|
39
|
-
|
|
40
|
-
|
|
142
|
+
- `kind: "soap"` is the marker that tells the loader "this service's entities are SOAP-backed".
|
|
143
|
+
- `wsdl` — relative path from `cds.root`. Loaded once per service, cached per destination.
|
|
144
|
+
- `credentials.destination` — SAP BTP destination name; JWT + basic-auth resolution goes through `@sap-cloud-sdk/connectivity`.
|
|
145
|
+
|
|
146
|
+
**2. Declare the entity in `srv/external/BP.cds`.**
|
|
147
|
+
|
|
148
|
+
```cds
|
|
149
|
+
using from '@cap-ts/soap-adapter';
|
|
150
|
+
|
|
151
|
+
@Soap
|
|
152
|
+
service BP {
|
|
153
|
+
@Soap.operation : 'GetBusinessPartner'
|
|
154
|
+
@Soap.binding : {
|
|
155
|
+
rootRequest : 'GetBusinessPartnerRequest',
|
|
156
|
+
rootResponse : 'GetBusinessPartnerResponse'
|
|
157
|
+
}
|
|
158
|
+
entity BusinessPartner {
|
|
159
|
+
@Soap.path : '<path of BusinessPartnerID from response body>'
|
|
160
|
+
key BusinessPartnerID : String;
|
|
161
|
+
@Soap.path : '<path of FullName from response body>'
|
|
162
|
+
FullName : String;
|
|
163
|
+
@Soap.path : '<path of CountryCode from response body>'
|
|
164
|
+
CountryCode : String;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**3. Ship an adapter subclass at `srv/external/lib/BP.js`.**
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
const { soap } = require('@cap-ts/soap-adapter');
|
|
173
|
+
|
|
174
|
+
class BP extends soap.ApplicationService {
|
|
175
|
+
init() {
|
|
176
|
+
const { BusinessPartner } = this.entities;
|
|
177
|
+
|
|
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
|
+
this.request(BusinessPartner, (req, payload) => {
|
|
185
|
+
// Shape the SOAP body from the incoming CAP $filter.
|
|
186
|
+
const key = req.query?.SELECT?.where; // CQN WHERE array
|
|
187
|
+
return { BusinessPartnerID: extractBpId(key) };
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
this.response(BusinessPartner, (req, rows) => {
|
|
191
|
+
// Rows have already been flattened per `@Soap.path` — massage further if needed.
|
|
192
|
+
return rows.map(r => ({ ...r, FullName: r.FullName?.trim() }));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
module.exports = BP;
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**4. Start `cds watch`.** The loader will mount:
|
|
200
|
+
|
|
201
|
+
- `/soap/BP/BusinessPartner` — raw SOAP-over-HTTP route (@requires-enforced).
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 📝 CDS annotation reference (`@Soap.*`)
|
|
206
|
+
|
|
207
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
208
|
+
|
|
209
|
+
All six annotations are declared in the package's `index.cds` under the single canonical top-level namespace. 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
|
+
|
|
211
|
+
### `@Soap.binding` *(struct, entity-level)*
|
|
212
|
+
|
|
213
|
+
Binds an entity to a concrete SOAP request/response element pair.
|
|
214
|
+
|
|
215
|
+
```cds
|
|
216
|
+
@Soap.binding: {
|
|
217
|
+
rootRequest : 'GetBusinessPartnerRequest', // outbound wrapper element
|
|
218
|
+
rootResponse : 'GetBusinessPartnerResponse' // inbound element to unwrap
|
|
219
|
+
}
|
|
220
|
+
entity BusinessPartner { … }
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
| Field | Required | Notes |
|
|
224
|
+
| ----- | -------- | ----- |
|
|
225
|
+
| `rootRequest` | Yes | SOAP element name used to wrap the outbound payload. Must match the WSDL operation's `<input message>` root. |
|
|
226
|
+
| `rootResponse` | No | SOAP response element whose contents become the CAP result. If omitted the loader falls back to `@Soap.rootResponse` (scalar), then to a heuristic derived from the operation name. |
|
|
227
|
+
|
|
228
|
+
### `@Soap.operation` *(scalar, entity-level)* — **required**
|
|
229
|
+
|
|
230
|
+
WSDL operation name serviced for SELECT / READ requests on this entity.
|
|
231
|
+
|
|
232
|
+
```cds
|
|
233
|
+
@Soap.operation: 'GetBusinessPartner'
|
|
234
|
+
entity BusinessPartner { … }
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
An entity without an `@Soap.operation` is invisible to the loader — no route is mounted, no CAP handler registered. This is the load-time gate.
|
|
238
|
+
|
|
239
|
+
### `@Soap.rootResponse` *(scalar, entity-level)* — optional
|
|
240
|
+
|
|
241
|
+
Only consulted when `@Soap.binding.rootResponse` is absent. Documents the SOAP response element to unwrap.
|
|
242
|
+
|
|
243
|
+
### `@Soap.path` *(scalar, element-level)*
|
|
244
|
+
|
|
245
|
+
Explicit dot-path override applied when the orchestrator walks a raw SOAP response tree to locate the value for this element.
|
|
246
|
+
|
|
247
|
+
```cds
|
|
248
|
+
entity BusinessPartner {
|
|
249
|
+
key BusinessPartnerID : String;
|
|
250
|
+
@Soap.path : 'data.Address.CountryCode'
|
|
251
|
+
CountryCode : String;
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Without `@Soap.path`, the orchestrator matches by element name (case-sensitive) and falls back to a structural best-guess. `@Soap.path` short-circuits both, which is essential when SOAP responses nest values several levels deep or when element names collide across namespaces.
|
|
256
|
+
|
|
257
|
+
### `@Soap.filterRestriction` *(struct, entity-level)*
|
|
258
|
+
|
|
259
|
+
Per-entity filter policy enforced by the runtime **before** dispatch.
|
|
260
|
+
|
|
261
|
+
```cds
|
|
262
|
+
@Soap.filterRestriction: {
|
|
263
|
+
mandatoryFields : [ 'BusinessPartnerID' ],
|
|
264
|
+
multipleSelection : false
|
|
265
|
+
}
|
|
266
|
+
entity BusinessPartner { … }
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
| Field | Type | Behaviour |
|
|
270
|
+
| ----- | ---- | --------- |
|
|
271
|
+
| `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 `true`) | When `false`, `$filter` on any mandatory field must be a single equality clause. `in(…)` / `or` / `ne` combinations are rejected as 400. The runtime expands `key in (a,b,c)` into N parallel SOAP calls only when `multipleSelection: true`. |
|
|
273
|
+
|
|
274
|
+
See § [Filter push-down & `filterRestriction`](#-filter-push-down--filterrestriction) below.
|
|
275
|
+
|
|
276
|
+
### `@Soap.adapter` *(scalar, entity- or service-level)* — optional
|
|
277
|
+
|
|
278
|
+
npm specifier of the adapter module implementing the request / response / mock / header hooks. Resolved at boot.
|
|
279
|
+
|
|
280
|
+
```cds
|
|
281
|
+
@Soap.adapter : '@my-org/bp-adapter' // sibling package
|
|
282
|
+
service BP { … }
|
|
283
|
+
|
|
284
|
+
// or, per-entity override:
|
|
285
|
+
@Soap.adapter : '@my-org/bp-adapter/dist/BusinessPartner'
|
|
286
|
+
entity BusinessPartner { … }
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
#### Resolution order
|
|
41
290
|
|
|
42
|
-
|
|
291
|
+
1. Entity-level `@Soap.adapter` (if set).
|
|
292
|
+
2. Service-level `@Soap.adapter` (if set).
|
|
293
|
+
3. Filesystem convention: `<dir-of-the-.cds-file>/lib/<ServiceShortName>.{ts,js}`.
|
|
43
294
|
|
|
44
|
-
|
|
295
|
+
On specifier resolution failure the loader logs a diagnostic (`adapter specifier X declared on Y could not be resolved from Z: … — falling back to filesystem convention`) and continues with the filesystem probe. This means: an unresolvable specifier never crashes boot, but you get a loud line to grep for.
|
|
45
296
|
|
|
46
|
-
|
|
297
|
+
Use `@Soap.adapter` when:
|
|
298
|
+
|
|
299
|
+
- The adapter class is shipped from a sibling package in a monorepo.
|
|
300
|
+
- The `.cds` is pre-compiled and `$location.file` is unavailable.
|
|
301
|
+
- You want to compose adapters across packages, or override the convention per entity.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## 🔌 Adapter class API (`soap.ApplicationService`)
|
|
306
|
+
|
|
307
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
308
|
+
|
|
309
|
+
Every SOAP-backed service ships an adapter class that extends `soap.ApplicationService`. The class is instantiated once per service at boot and its `init()` method is called to register per-entity hooks.
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
const { soap } = require('@cap-ts/soap-adapter');
|
|
313
|
+
|
|
314
|
+
class BP extends soap.ApplicationService {
|
|
315
|
+
init() {
|
|
316
|
+
const { BusinessPartner } = this.entities;
|
|
317
|
+
// register hooks …
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
module.exports = BP;
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Constructor properties
|
|
324
|
+
|
|
325
|
+
Available inside `init()` via `this.*`:
|
|
326
|
+
|
|
327
|
+
| Property | Type | Description |
|
|
328
|
+
| -------- | ---- | ----------- |
|
|
329
|
+
| `this.entities` | `Record<string, Csn.Entity>` | CSN entity definitions for the service, keyed by entity short name. |
|
|
330
|
+
| `this.model` | `Csn.Model` | Full CSN model for the service. |
|
|
331
|
+
| `this.options` | `object` | Merged `cds.requires.<ServiceName>` config object (wsdl path, credentials, …). |
|
|
332
|
+
|
|
333
|
+
### `this.header(entity, fn)` — outbound HTTP headers
|
|
334
|
+
|
|
335
|
+
Registers a hook that produces additional HTTP headers for every outbound SOAP call for `entity`.
|
|
336
|
+
|
|
337
|
+
```js
|
|
338
|
+
this.header(BusinessPartner, (req) => ({
|
|
339
|
+
'SOAPAction': '"GetBusinessPartner"',
|
|
340
|
+
'X-Correlation-ID': req.headers['x-correlation-id'] ?? crypto.randomUUID()
|
|
341
|
+
}));
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- `req` — the incoming CAP `Request` object.
|
|
345
|
+
- Return value: a plain `Record<string, string>`. Merged on top of the base headers computed by the runtime. Returning `null` / `undefined` is a no-op.
|
|
346
|
+
- Called once per SOAP dispatch (including fan-out calls for `multipleSelection: true`).
|
|
347
|
+
|
|
348
|
+
### `this.request(entity, fn)` — outbound SOAP payload
|
|
349
|
+
|
|
350
|
+
Registers a hook that shapes the SOAP request body for `entity`.
|
|
351
|
+
|
|
352
|
+
```js
|
|
353
|
+
this.request(BusinessPartner, (req, payload) => {
|
|
354
|
+
// payload is the runtime-built object before XML serialisation.
|
|
355
|
+
// Augment or replace it entirely.
|
|
356
|
+
return { ...payload, Language: req.locale ?? 'EN' };
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
- `req` — incoming CAP `Request`.
|
|
361
|
+
- `payload` — the pre-built payload object (already includes filter-extracted fields). Mutate or replace.
|
|
362
|
+
- Return value: the object to serialise into the SOAP `<Body>` wrapped under `rootRequest`.
|
|
363
|
+
- Returning `undefined` falls back to the runtime-built payload.
|
|
364
|
+
|
|
365
|
+
### `__filterPushedDown` sentinel
|
|
366
|
+
|
|
367
|
+
To signal that your `request()` hook has already embedded `$filter` / `$orderby` / `$top` / `$skip` into the outbound SOAP payload, return an object with `__filterPushedDown: true` from the hook. The runtime reads and **strips** the sentinel before the SOAP call, then skips the in-memory `$filter` / `$orderby` / `$top` / `$skip` evaluator — only `$select` projection and `$count` still run.
|
|
368
|
+
|
|
369
|
+
This is the single-hook pattern: one `request()` call does both payload building and push-down signalling.
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
async init() {
|
|
373
|
+
this.request(BusinessPartner, async (req) => {
|
|
374
|
+
const payload = this._buildSelection(req.query);
|
|
375
|
+
(payload as any).__filterPushedDown = true; // signal: skip in-memory eval
|
|
376
|
+
return payload;
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
- **Sentinel is stripped before SOAP dispatch.** The `__filterPushedDown` key is never forwarded to the backend; the runtime deletes it from a shallow clone of the payload.
|
|
382
|
+
- **Last writer wins.** If multiple `request()` handlers are registered for the same entity, the sentinel is read from the final merged payload after all handlers have run.
|
|
383
|
+
- **Feature-flagged.** Set `cds.env.soap.pushFilters: false` to force the in-memory path even when the sentinel is present.
|
|
384
|
+
- **No separate hook needed.** There is no `translateFilter` method to override — the `request()` hook is the single integration point for both payload building and push-down signalling.
|
|
385
|
+
|
|
386
|
+
**When push-down is not set** (sentinel absent or `pushFilters: false`), the runtime falls back to the bounded in-memory evaluator (`cds.env.soap.pageSize`, default `1000`, capped at `5000`) and emits an `@odata.nextLink` header when the result exceeds one page.
|
|
387
|
+
|
|
388
|
+
### `this.mock(entity, fn)` — offline / test mock
|
|
389
|
+
|
|
390
|
+
Registers a mock handler that **replaces** the actual SOAP call. Useful for unit tests and offline development.
|
|
391
|
+
|
|
392
|
+
```js
|
|
393
|
+
this.mock(BusinessPartner, (req) => {
|
|
394
|
+
return [{ BusinessPartnerID: 'BP001', FullName: 'ACME Corp' }];
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- Return value: array of raw row objects (same shape as the SOAP response would produce — **before** `@Soap.path` flattening).
|
|
399
|
+
- When a mock is registered the runtime skips destination resolution, WSDL loading, and HTTP dispatch entirely.
|
|
400
|
+
- Mocks are **not** active in production builds (`NODE_ENV=production`); registering one logs a `warn`.
|
|
401
|
+
|
|
402
|
+
### `this.response(entity, fn)` — inbound result transform
|
|
403
|
+
|
|
404
|
+
Registers a hook that post-processes the result before they are returned to the CAP OData layer.
|
|
405
|
+
|
|
406
|
+
```js
|
|
407
|
+
this.response(BusinessPartner, (req, rows) => {
|
|
408
|
+
return rows
|
|
409
|
+
.filter(r => r.Active === 'X')
|
|
410
|
+
.map(r => ({ ...r, FullName: r.FullName?.trim() ?? '' }));
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
- `req` — the incoming CAP `Request`.
|
|
415
|
+
- `rows` — array of objects already flattened by `@Soap.path` resolution. Each object has one key per entity element.
|
|
416
|
+
- Return value: the final rows array passed to CAP. Must be an array; returning `undefined` falls back to `rows` unchanged.
|
|
417
|
+
|
|
418
|
+
### `soap.ApplicationService.stripSensitiveHeaders(headers)` — static helper
|
|
419
|
+
|
|
420
|
+
Strips inbound HTTP headers that must never be forwarded to a backend SOAP service (auth tokens, session cookies, SAP passport, trace/correlation identifiers).
|
|
421
|
+
|
|
422
|
+
```js
|
|
423
|
+
this.header(BusinessPartner, (req) => ({
|
|
424
|
+
...soap.ApplicationService.stripSensitiveHeaders(req.headers ?? {}),
|
|
425
|
+
'SOAPAction': '"GetBusinessPartner"'
|
|
426
|
+
}));
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Removed header names (case-insensitive):
|
|
430
|
+
|
|
431
|
+
| Header | Category |
|
|
432
|
+
| ------ | -------- |
|
|
433
|
+
| `authorization` | Auth |
|
|
434
|
+
| `proxy-authorization` | Auth |
|
|
435
|
+
| `cookie` | Session |
|
|
436
|
+
| `set-cookie` | Session |
|
|
437
|
+
| `x-forwarded-for` | Forwarded identity |
|
|
438
|
+
| `x-forwarded-host` | Forwarded identity |
|
|
439
|
+
| `x-forwarded-proto` | Forwarded identity |
|
|
440
|
+
| `x-forwarded-authorization` | Forwarded identity |
|
|
441
|
+
| `x-real-ip` | Forwarded identity |
|
|
442
|
+
| `x-request-id` | Trace |
|
|
443
|
+
| `x-correlation-id` | Trace |
|
|
444
|
+
| `x-vcap-request-id` | Trace (CF) |
|
|
445
|
+
| `x-cf-app-instance` | Trace (CF) |
|
|
446
|
+
| `x-b3-traceid` | Trace (B3) |
|
|
447
|
+
| `x-b3-spanid` | Trace (B3) |
|
|
448
|
+
| `x-b3-parentspanid` | Trace (B3) |
|
|
449
|
+
| `x-b3-sampled` | Trace (B3) |
|
|
450
|
+
| `traceparent` | Trace (W3C) |
|
|
451
|
+
| `tracestate` | Trace (W3C) |
|
|
452
|
+
| `sap-passport` | SAP |
|
|
453
|
+
| `saml-token` | SAP |
|
|
454
|
+
| `x-csrf-token` | SAP |
|
|
455
|
+
| `sec-websocket-key` | WebSocket |
|
|
456
|
+
| `sec-websocket-accept` | WebSocket |
|
|
457
|
+
|
|
458
|
+
The full list is exposed as the frozen constant `soap.ApplicationService.FORBIDDEN_SOAP_HEADERS` (a `readonly string[]`). Consumers can inspect or extend it:
|
|
459
|
+
|
|
460
|
+
```js
|
|
461
|
+
const { soap } = require('@cap-ts/soap-adapter');
|
|
462
|
+
|
|
463
|
+
// Inspect the built-in list
|
|
464
|
+
console.log(soap.ApplicationService.FORBIDDEN_SOAP_HEADERS);
|
|
465
|
+
|
|
466
|
+
// Extend with your own headers (create a new Set — do not mutate the frozen array)
|
|
467
|
+
const myForbidden = new Set([
|
|
468
|
+
...soap.ApplicationService.FORBIDDEN_SOAP_HEADERS,
|
|
469
|
+
'x-my-internal-token',
|
|
470
|
+
]);
|
|
471
|
+
this.header(BusinessPartner, (req) => {
|
|
472
|
+
const clean = {};
|
|
473
|
+
for (const [k, v] of Object.entries(req.headers ?? {})) {
|
|
474
|
+
if (!myForbidden.has(k.toLowerCase())) clean[k] = v;
|
|
475
|
+
}
|
|
476
|
+
return clean;
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## 🧰 Utility API (`soap.*`)
|
|
483
|
+
|
|
484
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
485
|
+
|
|
486
|
+
Beyond `ApplicationService`, the package exports a set of standalone utilities available directly on the `soap` namespace. These cover the low-level primitives the orchestrator uses internally — consumers can call them directly when building advanced adapters, testing utilities, or routing logic.
|
|
487
|
+
|
|
488
|
+
```js
|
|
489
|
+
const { soap } = require('@cap-ts/soap-adapter');
|
|
490
|
+
// soap.createRequest(...)
|
|
491
|
+
// soap.read(...)
|
|
492
|
+
// soap.getEntityKeyFields(...)
|
|
493
|
+
// soap.dedupByKeys(...)
|
|
494
|
+
// soap.isSoapService(...)
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### `soap.createRequest(opts)` — build a typed `cds.Request` for SOAP dispatch
|
|
498
|
+
|
|
499
|
+
Creates a real `cds.Request` instance suitable for SOAP dispatch. Every request gets a fresh `context.id` (`'soap-' + UUID`).
|
|
500
|
+
|
|
501
|
+
```js
|
|
502
|
+
const req = soap.createRequest({
|
|
503
|
+
event: 'READ',
|
|
504
|
+
query: SELECT.from('BusinessPartner').where({ BusinessPartnerID: 'BP001' }),
|
|
505
|
+
target: srv.entities.BusinessPartner,
|
|
506
|
+
user: inboundReq.user,
|
|
507
|
+
http: inboundReq.http,
|
|
508
|
+
headers: inboundReq.headers,
|
|
509
|
+
});
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
| Parameter | Type | Required | Description |
|
|
513
|
+
| --------- | ---- | -------- | ----------- |
|
|
514
|
+
| `event` | `string` | Yes | CAP event name, e.g. `'READ'`. |
|
|
515
|
+
| `query` | `object` | Yes | CQN query object. |
|
|
516
|
+
| `data` | `object` | No | Request data / query parameters. |
|
|
517
|
+
| `headers` | `object` | No | HTTP headers (shallow-cloned to avoid mutating the caller's object). |
|
|
518
|
+
| `target` | `object` | No | CDS entity definition (`req.target`). |
|
|
519
|
+
| `params` | `any[]` | No | By-key params array (`req.params`). |
|
|
520
|
+
| `user` | `object` | No | `cds.User` instance (`req.user`). |
|
|
521
|
+
| `http` | `{req,res}` | No | Express req/res pair. |
|
|
522
|
+
|
|
523
|
+
Returns a `cds.Request` instance.
|
|
524
|
+
|
|
525
|
+
### `soap.read(srv, entity, req, query, opts?)` — one-shot SOAP read
|
|
526
|
+
|
|
527
|
+
One-shot SOAP read: assemble headers, build a real `cds.Request`, dispatch, and deduplicate the result by entity key fields.
|
|
528
|
+
|
|
529
|
+
```js
|
|
530
|
+
const rows = await soap.read(
|
|
531
|
+
srv, // ApplicationService instance
|
|
532
|
+
'BusinessPartner', // entity short name (key into srv.entities)
|
|
533
|
+
req, // inbound CAP request (source of user, params, headers)
|
|
534
|
+
req.query, // CQN SELECT query
|
|
535
|
+
{ // optional overrides
|
|
536
|
+
headwrs: ...,
|
|
537
|
+
params: ...,
|
|
538
|
+
outerReq: req,
|
|
539
|
+
fallbackEntityDef: ...
|
|
540
|
+
}
|
|
541
|
+
);
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
| Parameter | Type | Required | Description |
|
|
545
|
+
| --------- | ---- | -------- | ----------- |
|
|
546
|
+
| `srv` | `ApplicationService` | Yes | The SOAP adapter instance. |
|
|
547
|
+
| `entity` | `string` | Yes | Unqualified entity short name, e.g. `'BusinessPartner'`. |
|
|
548
|
+
| `req` | `cds.Request` | Yes | Source of `user`, `params`, and `headers`. |
|
|
549
|
+
| `query` | `object` | Yes | CQN SELECT query passed to `createRequest`. |
|
|
550
|
+
| `opts.headers` | `object` | No | Override headers entirely (still stripped before dispatch). |
|
|
551
|
+
| `opts.params` | `any[]` | No | Override `inboundReq.params`. |
|
|
552
|
+
| `opts.outerReq` | `cds.Request` | No | Preferred header source when the inner tx request has lost headers (e.g. inside `$expand` fan-out). |
|
|
553
|
+
| `opts.fallbackEntityDef` | `object` | No | CSN entity definition used for key resolution when `srv.entities[entityName]` is absent. |
|
|
554
|
+
|
|
555
|
+
Returns `Promise<any[]>` — always an array; `[]` when dispatch returns nothing.
|
|
556
|
+
|
|
557
|
+
### `soap.getEntityKeyFields(definition)` — resolve entity key names
|
|
558
|
+
|
|
559
|
+
Returns the flat list of key-field names for a CSN entity definition. Prefers `definition.keys` (present on all CAP-compiled entities) and falls back to scanning `definition.elements` for entries with `key: true`.
|
|
560
|
+
|
|
561
|
+
```js
|
|
562
|
+
const keys = soap.getEntityKeyFields(srv.entities.BusinessPartner);
|
|
563
|
+
// => ['BusinessPartnerID']
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
| Parameter | Type | Description |
|
|
567
|
+
| --------- | ---- | ----------- |
|
|
568
|
+
| `definition` | `object` | CSN entity definition, e.g. `srv.entities.Foo`. |
|
|
569
|
+
|
|
570
|
+
Returns `string[]` — zero or more key-field names.
|
|
571
|
+
|
|
572
|
+
### `soap.dedupByKeys(rows, keyFields)` — deduplicate rows by composite key
|
|
573
|
+
|
|
574
|
+
Deduplicates a flat row array by the given composite key fields. The first occurrence of each composite key wins. Rows with any `null` / `undefined` key value are passed through unchanged (missing keys cannot establish identity, so dropping them would silently lose data). When `keyFields` is empty the input is returned unchanged.
|
|
575
|
+
|
|
576
|
+
```js
|
|
577
|
+
const unique = soap.dedupByKeys(rows, ['BusinessPartnerID']);
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
| Parameter | Type | Description |
|
|
581
|
+
| --------- | ---- | ----------- |
|
|
582
|
+
| `rows` | `any[]` | Input row array. |
|
|
583
|
+
| `keyFields` | `string[]` | Composite key field names. |
|
|
584
|
+
|
|
585
|
+
Returns `any[]` — deduped rows in original order.
|
|
586
|
+
|
|
587
|
+
> **Note.** The composite key is joined with `'||'` as separator. Avoid field values that contain `'||'` if key collision across fields would be a concern.
|
|
588
|
+
|
|
589
|
+
### `soap.isSoapService(serviceName)` — check if a service is SOAP-backed
|
|
590
|
+
|
|
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`.
|
|
592
|
+
|
|
593
|
+
```js
|
|
594
|
+
if (soap.isSoapService('BP')) {
|
|
595
|
+
// route through SOAP adapter
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
| Parameter | Type | Description |
|
|
600
|
+
| --------- | ---- | ----------- |
|
|
601
|
+
| `serviceName` | `string` | The service name as registered in `cds.requires`. |
|
|
602
|
+
|
|
603
|
+
Returns `boolean`.
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
## ⚙️ Configuration (`cds.env.soap.*`)
|
|
608
|
+
|
|
609
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
610
|
+
|
|
611
|
+
Global runtime knobs live under `cds.env.soap` (i.e., the `cds.soap` key in `package.json` / `.cdsrc.json`).
|
|
612
|
+
|
|
613
|
+
```json
|
|
614
|
+
{
|
|
615
|
+
"cds": {
|
|
616
|
+
"soap": {
|
|
617
|
+
"maxConcurrent": 50,
|
|
618
|
+
"pushFilters": true,
|
|
619
|
+
"pageSize": 1000,
|
|
620
|
+
"idempotency": {
|
|
621
|
+
"enabled": false,
|
|
622
|
+
"ttl": 300000,
|
|
623
|
+
"maxEntries": 1000
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
| Key | Type | Default | Description |
|
|
631
|
+
| --- | ---- | ------- | ----------- |
|
|
632
|
+
| `soap.maxConcurrent` | `number` | `50` | Maximum number of parallel outbound SOAP calls per service instance. Enforced by `p-limit`. Fan-out calls (from `multipleSelection: true`) share this pool. |
|
|
633
|
+
| `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. |
|
|
634
|
+
| `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. |
|
|
635
|
+
| `soap.idempotency.enabled` | `boolean` | `false` | Feature flag — reserved for write-handler support (not yet shipped). When `true`, duplicate SOAP requests within the TTL window are short-circuited with a cached response. |
|
|
636
|
+
| `soap.idempotency.ttl` | `number` (ms) | `300000` | Cache TTL for idempotency keys (5 minutes). |
|
|
637
|
+
| `soap.idempotency.maxEntries` | `number` | `1000` | LRU eviction threshold for the idempotency cache. |
|
|
638
|
+
|
|
639
|
+
Per-service configuration lives in `cds.requires.<ServiceName>`:
|
|
640
|
+
|
|
641
|
+
| Key | Type | Required | Description |
|
|
642
|
+
| --- | ---- | -------- | ----------- |
|
|
643
|
+
| `kind` | `"soap"` | **Yes** | Marks the service for SOAP handling. |
|
|
644
|
+
| `wsdl` | `string` | **Yes** | Path to the WSDL file, relative to `cds.root`. |
|
|
645
|
+
| `credentials.destination` | `string` | Yes (BTP) | BTP destination name. Resolved via `@sap-cloud-sdk/connectivity`. |
|
|
646
|
+
| `credentials.url` | `string` | Yes (local) | Direct SOAP endpoint URL. Used when no BTP destination is configured (local dev / on-premise). |
|
|
647
|
+
| `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). |
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
## 🏢 Multi-tenant destinations & JWT propagation
|
|
652
|
+
|
|
653
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
654
|
+
|
|
655
|
+
In a multi-tenant SaaS application each subscriber has its own BTP destination. The adapter resolves the destination **per request** using the subscriber JWT from the incoming `Authorization` header, so no code change is needed between single-tenant and multi-tenant deployments.
|
|
656
|
+
|
|
657
|
+
### How it works
|
|
658
|
+
|
|
659
|
+
1. The CAP runtime validates the incoming JWT and attaches the tenant context to `req`.
|
|
660
|
+
2. On each SOAP dispatch the adapter calls `@sap-cloud-sdk/connectivity`'s `getDestination()` with the subscriber tenant token extracted from `req.http.req.headers.authorization`.
|
|
661
|
+
3. The destination service returns the tenant-specific endpoint URL and credentials.
|
|
662
|
+
4. The WSDL is re-fetched (and cached per tenant+destination) if the endpoint differs from a previously seen one.
|
|
663
|
+
|
|
664
|
+
### Configuration
|
|
665
|
+
|
|
666
|
+
No extra configuration is needed beyond the standard `credentials.destination` key:
|
|
667
|
+
|
|
668
|
+
```json
|
|
669
|
+
{
|
|
670
|
+
"cds": {
|
|
671
|
+
"requires": {
|
|
672
|
+
"BP": {
|
|
673
|
+
"kind": "soap",
|
|
674
|
+
"wsdl": "srv/external/wsdl/BP.wsdl",
|
|
675
|
+
"credentials": { "destination": "DEST_BP" }
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
The destination name is the **provider** destination registered in BTP. For multi-tenant scenarios the Cloud SDK automatically looks up the subscriber-scoped destination first, then falls back to the provider destination — this is standard Cloud SDK behaviour and requires no adapter-specific setup.
|
|
683
|
+
|
|
684
|
+
### Local development without a BTP destination
|
|
685
|
+
|
|
686
|
+
Use `credentials.url` + optional `credentials.username` / `credentials.password` in your `cds.env` override (`.cdsrc-private.json` or environment variables):
|
|
687
|
+
|
|
688
|
+
```json
|
|
689
|
+
{
|
|
690
|
+
"cds": {
|
|
691
|
+
"requires": {
|
|
692
|
+
"BP": {
|
|
693
|
+
"credentials": {
|
|
694
|
+
"url": "http://localhost:8080/soap/BP",
|
|
695
|
+
"username": "alice",
|
|
696
|
+
"password": "Alice@Cap#"
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
> **Security note.** Never commit `credentials.password` to source control. Use `.cdsrc-private.json` (git-ignored) or the `CDS_REQUIRES_BP_CREDENTIALS_PASSWORD` environment variable pattern.
|
|
705
|
+
|
|
706
|
+
### JWT forwarding details
|
|
707
|
+
|
|
708
|
+
The adapter propagates the subscriber JWT as a Bearer token in the `Authorization` header of the outbound SOAP call **unless** the `header()` hook returns its own `Authorization` key (which takes precedence). Use `stripSensitiveHeaders()` in your `header()` hook if you want to suppress forwarding entirely:
|
|
709
|
+
|
|
710
|
+
```js
|
|
711
|
+
this.header(BusinessPartner, (req) => ({
|
|
712
|
+
...soap.ApplicationService.stripSensitiveHeaders(req.headers ?? {}),
|
|
713
|
+
// No Authorization key → runtime does NOT forward the inbound JWT.
|
|
714
|
+
'SOAPAction': '"GetBusinessPartner"'
|
|
715
|
+
}));
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
---
|
|
719
|
+
|
|
720
|
+
## 🔍 Filter push-down & `filterRestriction`
|
|
721
|
+
|
|
722
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
723
|
+
|
|
724
|
+
The adapter translates OData `$filter` expressions into SOAP request fields. Understanding the push-down model is essential for entities that require specific filter fields (like SAP S/4HANA BAPIs that mandate a company code or business partner key).
|
|
725
|
+
|
|
726
|
+
### How filter push-down works
|
|
727
|
+
|
|
728
|
+
Two paths are supported. **Prefer the opt-in path (A)** for any adapter that can express `$filter` / `$orderby` / `$top` / `$skip` in its SOAP operation's selection criteria — it avoids the in-memory evaluator entirely and lets the SOAP backend paginate.
|
|
729
|
+
|
|
730
|
+
**Path A — Opt-in via `__filterPushedDown` sentinel (recommended, since 0.1.6).**
|
|
731
|
+
|
|
732
|
+
1. The CAP OData layer parses `$filter` into a CQN `WHERE` array.
|
|
733
|
+
2. Your `request()` hook translates the CQN into the SOAP-native selection criteria and **returns the payload with `__filterPushedDown: true`** set on it (when `cds.env.soap.pushFilters !== false`).
|
|
734
|
+
3. The runtime reads and **strips** the sentinel from the payload before the SOAP call — the backend never sees it.
|
|
735
|
+
4. The in-memory `$filter` / `$orderby` / `$top` / `$skip` evaluator is **skipped** — the SOAP response is treated as the authoritative page. Only `$select` projection and `$count` still run.
|
|
736
|
+
|
|
737
|
+
See [`__filterPushedDown` sentinel](#__filterpusheddown-sentinel) for the full contract and an example implementation.
|
|
738
|
+
|
|
739
|
+
**Path B — Bounded in-memory fallback.**
|
|
740
|
+
|
|
741
|
+
1. The CAP OData layer parses `$filter` into a CQN `WHERE` array.
|
|
742
|
+
2. The loader's filter extractor walks the CQN tree and produces a flat `{ fieldName: value | value[] }` map.
|
|
743
|
+
3. The runtime passes this map to the `request()` hook as part of `payload`.
|
|
744
|
+
4. For `multipleSelection: false` entities with an `in(…)` filter, the runtime fans out into N sequential/parallel SOAP calls (one per value) and merges results.
|
|
745
|
+
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.
|
|
746
|
+
|
|
747
|
+
### `@Soap.filterRestriction` reference
|
|
748
|
+
|
|
749
|
+
```cds
|
|
750
|
+
@Soap.filterRestriction: {
|
|
751
|
+
mandatoryFields : [ 'CompanyCode', 'FiscalYear' ],
|
|
752
|
+
multipleSelection : false
|
|
753
|
+
}
|
|
754
|
+
entity e_FinancialDocument { … }
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
**`mandatoryFields`** — array of OData property names that MUST be present in `$filter`. A request missing any mandatory field is rejected with HTTP 400 and a diagnostic message:
|
|
758
|
+
|
|
759
|
+
```http
|
|
760
|
+
GET /soap/FI/e_FinancialDocument?$filter=FiscalYear eq '2025'
|
|
761
|
+
→ 400 Bad Request: Filter field 'CompanyCode' is mandatory for e_FinancialDocument
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
**`multipleSelection`** — controls fan-out behaviour:
|
|
765
|
+
|
|
766
|
+
| Value | `$filter` clause | Runtime behaviour |
|
|
767
|
+
| ----- | ---------------- | ----------------- |
|
|
768
|
+
| `true` (default) | `BusinessPartnerID in ('BP001','BP002')` | Expands to 2 parallel SOAP calls; results merged. |
|
|
769
|
+
| `false` | `BusinessPartnerID in ('BP001','BP002')` | Rejected with HTTP 400. |
|
|
770
|
+
| `false` | `BusinessPartnerID eq 'BP001'` | Single SOAP call. |
|
|
771
|
+
|
|
772
|
+
### Combining mandatory fields with `$expand`
|
|
773
|
+
|
|
774
|
+
When an entity is used as a value-help target (via `$expand` from a parent entity), the parent's key fields are automatically injected into the filter map — `mandatoryFields` validation still applies. Ensure the parent passes all required keys as association keys.
|
|
775
|
+
|
|
776
|
+
---
|
|
777
|
+
|
|
778
|
+
## 🔒 Security guarantees
|
|
779
|
+
|
|
780
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
781
|
+
|
|
782
|
+
| Guarantee | Detail |
|
|
783
|
+
| --- | --- |
|
|
784
|
+
| **No credential leakage** | `stripSensitiveHeaders()` removes auth, cookie, and SAP Passport headers before outbound dispatch. Adapters that call it in `header()` are safe by default. |
|
|
785
|
+
| **JWT tenant isolation** | Destination resolution uses the subscriber JWT; a tenant cannot access another tenant's destination. |
|
|
786
|
+
| **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. |
|
|
787
|
+
| **No WSDL caching across tenants** | WSDL cache is keyed by `tenant + destination name + endpoint URL`. A WSDL change on one tenant does not affect others. |
|
|
788
|
+
| **`@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. |
|
|
789
|
+
|
|
790
|
+
### Middleware ordering for consumers
|
|
791
|
+
|
|
792
|
+
The adapter registers its `/soap/**` routes during CAP's `served` phase. Any transport-layer security middleware (helmet, CORS, rate limiting, body-size caps) **must** be attached in the `bootstrap` phase so it wraps `/soap/**` requests too. The `serve` hook runs after `bootstrap`, so ordering is:
|
|
793
|
+
|
|
794
|
+
```flow
|
|
795
|
+
bootstrap (your middleware) → serve (CAP mounts routes) → served (adapter mounts /soap/**)
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
**Recommended snippet** — drop into `srv/server.ts` (or `.js`) alongside any other bootstrap wiring:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
import cds = require('@sap/cds');
|
|
802
|
+
import helmet = require('helmet');
|
|
803
|
+
import cors = require('cors');
|
|
804
|
+
import rateLimit = require('express-rate-limit');
|
|
805
|
+
|
|
806
|
+
cds.on('bootstrap', (app: any) => {
|
|
807
|
+
// 1. Security headers — must be first so it wraps everything downstream.
|
|
808
|
+
app.use(helmet());
|
|
809
|
+
|
|
810
|
+
// 2. CORS — restrict to your approuter / trusted origins. Never use `origin: true` in prod.
|
|
811
|
+
app.use(cors({
|
|
812
|
+
origin: process.env.CORS_ALLOWED_ORIGINS?.split(',') ?? false,
|
|
813
|
+
credentials: true,
|
|
814
|
+
}));
|
|
815
|
+
|
|
816
|
+
// 3. Rate limiting — protect SOAP endpoints from bursty callers.
|
|
817
|
+
app.use('/soap', rateLimit({
|
|
818
|
+
windowMs: 60_000, // 1 minute
|
|
819
|
+
max: 120, // 120 req/min/IP — tune for your workload
|
|
820
|
+
standardHeaders: true,
|
|
821
|
+
legacyHeaders: false,
|
|
822
|
+
}));
|
|
823
|
+
|
|
824
|
+
// 4. Then app-specific middleware (body parsers, cov2ap, custom routes)…
|
|
825
|
+
});
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
**Why the order matters:**
|
|
829
|
+
|
|
830
|
+
- `helmet()` and `cors()` before `rateLimit()` so rejected requests still get correct security headers.
|
|
831
|
+
- `rateLimit()` scoped to `/soap` avoids penalising OData v2/v4 traffic that flows through cov2ap.
|
|
832
|
+
- All three go **before** any `app.use(cov2ap(...))` or body parsers — Express matches middleware in registration order.
|
|
833
|
+
|
|
834
|
+
**Dependencies to add to your consuming package:**
|
|
835
|
+
|
|
836
|
+
```json
|
|
837
|
+
{
|
|
838
|
+
"dependencies": {
|
|
839
|
+
"helmet": "^7",
|
|
840
|
+
"cors": "^2.8",
|
|
841
|
+
"express-rate-limit": "^7"
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
> ⚠️ The adapter does **not** bundle these packages. Consumers own the security posture of their HTTP surface; the adapter only guarantees that its own `/soap/**` handlers do not leak credentials, cache across tenants, or accept unsanitised filter input (see the guarantees table above).
|
|
847
|
+
|
|
848
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
849
|
+
|
|
850
|
+
---
|
|
851
|
+
|
|
852
|
+
## 🛠️ Troubleshooting
|
|
853
|
+
|
|
854
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
855
|
+
|
|
856
|
+
### Entity not found / `501 Not Implemented`
|
|
857
|
+
|
|
858
|
+
**Symptom:** `GET /soap/BP/BusinessPartner` returns `501` or the entity is missing from the service document.
|
|
859
|
+
|
|
860
|
+
**Cause:** The loader skips entities without `@Soap.operation`. Check that:
|
|
861
|
+
|
|
862
|
+
1. The entity has `@Soap.operation: '<WsdlOperationName>'` in its `.cds` model.
|
|
863
|
+
2. The service is declared with `kind: "soap"` in `cds.requires`.
|
|
864
|
+
3. The `.cds` file is reachable from `cds.model` (i.e., it is `using`-imported somewhere in the model graph).
|
|
865
|
+
|
|
866
|
+
---
|
|
867
|
+
|
|
868
|
+
### Empty result set
|
|
869
|
+
|
|
870
|
+
**Symptom:** The SOAP call succeeds (HTTP 200) but the CAP result array is empty.
|
|
871
|
+
|
|
872
|
+
**Likely causes:**
|
|
873
|
+
|
|
874
|
+
1. **Wrong `rootResponse`** — the element name in `@Soap.binding.rootResponse` does not match the actual WSDL response wrapper. Open the raw SOAP response (enable `DEBUG=soap` logging) and verify the element name.
|
|
875
|
+
2. **Missing `@Soap.path`** — the orchestrator cannot locate the value for an element by name. Add explicit `@Soap.path` annotations.
|
|
876
|
+
3. **Filter not pushed down** — the `request()` hook is not reading the filter fields correctly. Log `payload` inside the hook to inspect.
|
|
877
|
+
|
|
878
|
+
---
|
|
879
|
+
|
|
880
|
+
### `@Soap.binding` is `undefined` at runtime
|
|
881
|
+
|
|
882
|
+
**Symptom:** The adapter crashes with `Cannot read properties of undefined (reading 'rootRequest')`.
|
|
883
|
+
|
|
884
|
+
**Cause:** You are reading `entity['@Soap.binding']` directly. The CDS compiler serialises struct annotations as **flat leaf CSN keys** (`entity['@Soap.binding.rootRequest']`), not as a nested object. The loader's `readSoapStructAnnotation` helper reassembles these — but only after the `served` event fires.
|
|
885
|
+
|
|
886
|
+
**Fix:** Read binding via the loader API (available after `cds.on('served', …)`), or use the post-loader entity definition from `this.entities` inside `init()`.
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
### WSDL not found / `ENOENT`
|
|
891
|
+
|
|
892
|
+
**Symptom:** Boot fails with `ENOENT: no such file or directory, open '<path>.wsdl'`.
|
|
893
|
+
|
|
894
|
+
**Fix:** The `wsdl` path in `cds.requires.<ServiceName>` is resolved relative to `cds.root` (the directory containing `package.json`). Verify the path with:
|
|
895
|
+
|
|
896
|
+
```bash
|
|
897
|
+
node -e "const cds = require('@sap/cds'); console.log(cds.root)"
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
---
|
|
901
|
+
|
|
902
|
+
### Adapter class not loaded / `TypeError: … is not a constructor`
|
|
903
|
+
|
|
904
|
+
**Symptom:** Boot logs `adapter specifier … could not be resolved` or throws on instantiation.
|
|
905
|
+
|
|
906
|
+
**Fix checklist:**
|
|
907
|
+
|
|
908
|
+
1. The module at the resolved path must `module.exports` the class (not `exports.MyClass`).
|
|
909
|
+
2. If using TypeScript, `ts-node` must be installed (`npm i -D ts-node`).
|
|
910
|
+
3. For monorepo sibling packages, ensure the package is listed in `dependencies` and `npm install` has run.
|
|
911
|
+
|
|
912
|
+
---
|
|
913
|
+
|
|
914
|
+
### Enable verbose logging
|
|
915
|
+
|
|
916
|
+
Set the `DEBUG` environment variable before starting `cds watch`:
|
|
917
|
+
|
|
918
|
+
```bash
|
|
919
|
+
DEBUG=soap cds watch
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
This enables per-request logging of: destination resolution, WSDL load, outbound headers (with sensitive values redacted), raw SOAP request/response XML, and filter extraction.
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
926
|
+
## 💬 Support & feedback
|
|
927
|
+
|
|
928
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
929
|
+
|
|
930
|
+
- **Bug reports & feature requests:** open an issue on the [GitHub repository](https://github.com/cap-ts/soap-adapter/issues).
|
|
931
|
+
- **Questions:** use [GitHub Discussions](https://github.com/orgs/cap-ts/discussions).
|
|
932
|
+
|
|
933
|
+
---
|
|
47
934
|
|
|
48
935
|
## 📄 License
|
|
49
936
|
|
|
937
|
+
[↑ Table of Contents](#-table-of-contents)
|
|
938
|
+
|
|
50
939
|
This package is provided under the terms of the **SAP-Code-World** [Usage License Agreement](LICENSE).
|
|
51
940
|
|
|
52
941
|
© 2025 **SAP-Code-World**. All rights reserved.
|