@cap-ts/soap-adapter 0.3.0 → 0.4.1
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 +135 -60
- package/index.cds +8 -9
- package/package.json +1 -1
- package/src/_license/index.js +1 -1
- 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/types/index.d.ts +8 -9
- package/types/lib/index.d.ts +69 -183
package/README.md
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
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 an OData-style JSON response (`@odata.context`, `@odata.count`, `@odata.nextLink`). It is read only: writes are rejected with 405.
|
|
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 class (the CAP service of the SOAP service, a subclass of `soap.Service`) 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
|
| Area | Features |
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| Model | Seven typed CDS annotations (`@Soap`, `@Soap.operation`, `@Soap.binding`, `@Soap.rootResponse`, `@Soap.path`, `@Soap.filterRestriction`, `@Soap.adapter`), checked by `cds compile` |
|
|
15
|
-
| Adapter
|
|
15
|
+
| Adapter class | A CAP service per SOAP service (`soap.Service`), found next to its `.cds` file (also in a `cds build` output), through `@Soap.adapter` or `cds.requires.<Service>.impl`; hooks `header()` (SOAP headers), `request()` (input message, push-down sentinels), `response()` (raw rows), `mock()` (offline), plus CAP's `before` / `on` / `after` |
|
|
16
16
|
| HTTP | `GET /soap/<path>/<Entity>` and `/<Entity>/<key>` with `$select`, `$filter`, `$orderby`, `$top`, `$skip` / `$skiptoken`, `$count`; OData-style JSON; `@requires` enforced |
|
|
17
17
|
| Queries | Fail-closed `$filter` parser, mandatory filter fields, one SOAP call per key value (`multipleSelection: false`), push-down or bounded in-memory evaluation with `@odata.nextLink` |
|
|
18
18
|
| In process | The SOAP service is a CAP service: other services read it with `srv.run(SELECT ...)` or `soap.read(...)` (e.g. `@cap-ts/remote-service-adapter`) |
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
📝 [CDS annotation reference (`@Soap.*`)](#-cds-annotation-reference-soap)\
|
|
30
30
|
🌐 [HTTP endpoints and query options](#-http-endpoints-and-query-options)\
|
|
31
31
|
🔗 [Using SOAP entities from other services](#-using-soap-entities-from-other-services)\
|
|
32
|
-
🔌 [Adapter class API (`soap.
|
|
32
|
+
🔌 [Adapter class API (`soap.Service`)](#-adapter-class-api-soapservice)\
|
|
33
33
|
🧰 [Utility API (`soap.*`)](#-utility-api-soap)\
|
|
34
34
|
⚙️ [Configuration (`cds.env.query.soap.*`)](#️-configuration-cdsenvquerysoap)\
|
|
35
35
|
🏢 [Multi-tenant destinations & JWT propagation](#-multi-tenant-destinations--jwt-propagation)\
|
|
@@ -97,12 +97,13 @@ Optional but recommended:
|
|
|
97
97
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
|
98
98
|
│ │ │
|
|
99
99
|
│ ▼ │
|
|
100
|
-
│ Consumer srv/lib/BP.js (extends soap.
|
|
100
|
+
│ Consumer srv/lib/BP.js (class BP extends soap.Service) │
|
|
101
101
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
|
102
|
-
│ │ init() {
|
|
102
|
+
│ │ async init() { │ │
|
|
103
103
|
│ │ this.header(BusinessPartner, req => ({ Authorization: … })); │ │
|
|
104
104
|
│ │ this.request(BusinessPartner, (req, payload) => …); │ │
|
|
105
105
|
│ │ this.response(BusinessPartner, (req, rows) => rows.map(…)); │ │
|
|
106
|
+
│ │ await super.init(); │ │
|
|
106
107
|
│ │ } │ │
|
|
107
108
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
|
108
109
|
└────────────────────────────────────────────────────────────────────────┘
|
|
@@ -116,7 +117,7 @@ Optional but recommended:
|
|
|
116
117
|
OData-style GET endpoint at `/soap/<service path>/<Entity>`
|
|
117
118
|
```
|
|
118
119
|
|
|
119
|
-
Two-word summary: **annotate + subclass**. The plugin needs both — the annotations tell it *what* to route, the subclass tells it *how* to marshal.
|
|
120
|
+
Two-word summary: **annotate + subclass**. The plugin needs both — the annotations tell it *what* to route, the subclass tells it *how* to marshal. The subclass is the CAP service of the SOAP service: one instance, constructed at boot.
|
|
120
121
|
|
|
121
122
|
---
|
|
122
123
|
|
|
@@ -131,7 +132,7 @@ srv/
|
|
|
131
132
|
external/
|
|
132
133
|
BP.cds ← SOAP service model
|
|
133
134
|
lib/
|
|
134
|
-
BP.js ← adapter
|
|
135
|
+
BP.js ← adapter class (extends soap.Service)
|
|
135
136
|
wsdl/
|
|
136
137
|
BP.wsdl ← WSDL cached locally
|
|
137
138
|
package.json
|
|
@@ -180,13 +181,13 @@ service BP {
|
|
|
180
181
|
}
|
|
181
182
|
```
|
|
182
183
|
|
|
183
|
-
**3. Ship
|
|
184
|
+
**3. Ship the adapter class at `srv/external/lib/BP.js`** (`.ts` works too; see [Finding the adapter class](#finding-the-adapter-class)).
|
|
184
185
|
|
|
185
186
|
```js
|
|
186
187
|
const { soap } = require('@cap-ts/soap-adapter');
|
|
187
188
|
|
|
188
|
-
class BP extends soap.
|
|
189
|
-
init() {
|
|
189
|
+
class BP extends soap.Service {
|
|
190
|
+
async init() {
|
|
190
191
|
const { BusinessPartner } = this.entities;
|
|
191
192
|
|
|
192
193
|
this.request(BusinessPartner, (req, payload) => {
|
|
@@ -199,12 +200,14 @@ class BP extends soap.ApplicationService {
|
|
|
199
200
|
// Rows are the raw entries under `rootResponse`; `@Soap.path` mapping runs after this hook.
|
|
200
201
|
return rows.filter(r => r != null);
|
|
201
202
|
});
|
|
203
|
+
|
|
204
|
+
await super.init(); // registers the SOAP READ pipeline
|
|
202
205
|
}
|
|
203
206
|
}
|
|
204
207
|
module.exports = BP;
|
|
205
208
|
```
|
|
206
209
|
|
|
207
|
-
**4. Start `cds watch`.** The loader mounts GET routes under `/soap` + the service's `@path`, or, without `@path`,
|
|
210
|
+
**4. Start `cds watch`.** The boot line names the class it found (`serving BP { at: ['/soap/bp'], decl: 'srv/external/BP.cds:3', impl: 'srv/external/lib/BP.js' }`). The loader mounts GET routes under `/soap` + the service's `@path`, or, without `@path`,
|
|
208
211
|
its short name in lower case:
|
|
209
212
|
|
|
210
213
|
- `/soap/bp/BusinessPartner` — the entity set (`$select`, `$count`, `$filter`, `$orderby`, `$top`, `$skip`; any other
|
|
@@ -227,9 +230,9 @@ Details: [HTTP endpoints and query options](#-http-endpoints-and-query-options).
|
|
|
227
230
|
| `@Soap.rootResponse: '<path>'` | entity | No | Same as `binding.rootResponse`, takes precedence. Default `<operation>Response`. |
|
|
228
231
|
| `@Soap.path: '<dot.path>'` | element | No | Where the element's value sits in one response entry. Default: the element name. |
|
|
229
232
|
| `@Soap.filterRestriction: { mandatoryFields, multipleSelection }` | entity | No | Fields `$filter` must contain (400 otherwise); `multipleSelection: false` = one SOAP call per key value. |
|
|
230
|
-
| `@Soap.adapter: '<
|
|
233
|
+
| `@Soap.adapter: '<module>'` | service | No | Adapter module (a path relative to the project root, or a package), instead of `cds.requires.<Service>.impl` or the file convention. |
|
|
231
234
|
|
|
232
|
-
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.
|
|
235
|
+
All seven annotations (`@Soap` and the six `@Soap.*` below) are declared in the package's `index.cds` under the single canonical top-level namespace. `using from '@cap-ts/soap-adapter';` imports them; the model still compiles to SQL, so an application with a database deploys as usual. `@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.
|
|
233
236
|
|
|
234
237
|
### `@Soap.binding` *(struct, entity-level)*
|
|
235
238
|
|
|
@@ -275,7 +278,8 @@ entity BusinessPartner {
|
|
|
275
278
|
}
|
|
276
279
|
```
|
|
277
280
|
|
|
278
|
-
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
|
|
281
|
+
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; associations and compositions (for example one added with `extend` to join the SOAP entity to another service) are left out of the row. A value node-soap parsed into a date (`xsd:date`, `xsd:dateTime`, `xsd:time`) is formatted for
|
|
282
|
+
the element's type: `Date` `2026-10-14`, `Time` `13:45:00`, `DateTime` `2026-10-14T13:45:00Z`, else the ISO timestamp. `@Soap.path` is essential when SOAP responses nest values several levels deep or when element names differ from the response.
|
|
279
283
|
|
|
280
284
|
### `@Soap.filterRestriction` *(struct, entity-level)*
|
|
281
285
|
|
|
@@ -296,62 +300,120 @@ entity BusinessPartner { … }
|
|
|
296
300
|
|
|
297
301
|
See § [Filter push-down & `filterRestriction`](#-filter-push-down--filterrestriction) below.
|
|
298
302
|
|
|
299
|
-
### `@Soap.adapter` *(scalar,
|
|
303
|
+
### `@Soap.adapter` *(scalar, service-level)* — optional
|
|
300
304
|
|
|
301
|
-
|
|
305
|
+
The module of the service's adapter class: a path relative to the project root (with or without `./`, without
|
|
306
|
+
extension: `<path>.ts` is taken before `<path>.js`), or a package specifier resolved from the project root.
|
|
302
307
|
|
|
303
308
|
```cds
|
|
304
|
-
@Soap.adapter : '@my-org/bp-adapter'
|
|
309
|
+
@Soap.adapter : '@my-org/bp-adapter' // sibling package
|
|
305
310
|
service BP { … }
|
|
306
311
|
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
entity BusinessPartner { … }
|
|
312
|
+
@Soap.adapter : 'srv/adapters/BusinessPartner' // <root>/srv/adapters/BusinessPartner.ts, else .js
|
|
313
|
+
service BP2 { … }
|
|
310
314
|
```
|
|
311
315
|
|
|
312
|
-
|
|
316
|
+
The adapter class is the service, so `@Soap.adapter` belongs on the service. On an entity it stops the start
|
|
317
|
+
(`@Soap.adapter on entity "<Entity>" is not supported since @cap-ts/soap-adapter 0.4`): register the entity's hooks in
|
|
318
|
+
the service class instead.
|
|
319
|
+
|
|
320
|
+
#### Finding the adapter class
|
|
313
321
|
|
|
314
|
-
|
|
315
|
-
2. Service-level `@Soap.adapter` (if set).
|
|
316
|
-
3. Filesystem convention: `<dir-of-the-.cds-file>/lib/<ServiceShortName>.{ts,js}`.
|
|
322
|
+
At startup the plugin looks for the class of each SOAP service, in this order:
|
|
317
323
|
|
|
318
|
-
|
|
324
|
+
1. `@Soap.adapter` on the service.
|
|
325
|
+
2. `impl` in the service's configuration (`cds.requires.<Service>.impl`, CAP's key for a service implementation). Same
|
|
326
|
+
values as `@Soap.adapter`; profiles apply (`"[production]": { "impl": "…" }`).
|
|
327
|
+
3. The file convention: `<Service>.ts` or `<Service>.js` (`.ts` first) in the folder of the `.cds` file that declares
|
|
328
|
+
the service, in its `lib/` sub-folder, or in its `handlers/` sub-folder (the folders CAP searches as well).
|
|
319
329
|
|
|
320
|
-
|
|
330
|
+
The folder of rule 3 comes from the service's `@source`, which `cds build` writes into `csn.json`. So the same project
|
|
331
|
+
works under `cds watch` (the `.ts` file next to the `.cds` sources) and when it is served from the build output, e.g.
|
|
332
|
+
`gen/srv` on Cloud Foundry (the compiled `.js` file next to the compiled `.cds` folders).
|
|
321
333
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
334
|
+
A configured value that does not resolve is logged as an error and the next source is tried. The class must extend
|
|
335
|
+
`soap.Service`; a class of an earlier version stops the start with a [migration message](#migrating-from-03).
|
|
336
|
+
|
|
337
|
+
The boot line shows the result:
|
|
338
|
+
|
|
339
|
+
```text
|
|
340
|
+
serving BP {
|
|
341
|
+
at: ['/soap/bp'],
|
|
342
|
+
decl: 'srv/external/BP.cds:3',
|
|
343
|
+
impl: 'srv/external/lib/BP.js'
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
When nothing is found, the service still starts, its READs answer `501`, and a warning lists what was tried:
|
|
348
|
+
|
|
349
|
+
```text
|
|
350
|
+
[soap] no adapter class for BP; tried: @Soap.adapter (none), cds.requires.BP.impl (none), srv/external/{,lib/,handlers/}BP.{ts,js}. Its READs answer 501.
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
With `"strict": true` in `cds.requires.<Service>` (or `cds.env.features.strict`), a missing class stops the start
|
|
354
|
+
instead, so a broken build is caught at deployment and not at the first request.
|
|
325
355
|
|
|
326
356
|
---
|
|
327
357
|
|
|
328
|
-
## 🔌 Adapter class API (`soap.
|
|
358
|
+
## 🔌 Adapter class API (`soap.Service`)
|
|
329
359
|
|
|
330
360
|
[↑ Table of Contents](#-table-of-contents)
|
|
331
361
|
|
|
332
|
-
Every SOAP-backed service ships an adapter class that extends `soap.
|
|
362
|
+
Every SOAP-backed service ships an adapter class that extends `soap.Service`. `soap.Service` is a CAP service
|
|
363
|
+
(`cds.Service`): the plugin constructs the class **once per service** at boot, like CAP constructs any service
|
|
364
|
+
(`new BP(name, model, options)`), and awaits its `init()`. Register the hooks in `init()` and end it with
|
|
365
|
+
`await super.init()`, which registers the SOAP READ pipeline.
|
|
333
366
|
|
|
334
367
|
```js
|
|
335
368
|
const { soap } = require('@cap-ts/soap-adapter');
|
|
336
369
|
|
|
337
|
-
class BP extends soap.
|
|
338
|
-
init() {
|
|
370
|
+
class BP extends soap.Service {
|
|
371
|
+
async init() {
|
|
339
372
|
const { BusinessPartner } = this.entities;
|
|
340
373
|
// register hooks …
|
|
374
|
+
await super.init();
|
|
341
375
|
}
|
|
342
376
|
}
|
|
343
377
|
module.exports = BP;
|
|
344
378
|
```
|
|
345
379
|
|
|
346
|
-
|
|
380
|
+
- **No request state on `this`.** One instance serves every request; each hook gets the current request as `req`.
|
|
381
|
+
- **CAP handlers work as usual.** `this.before`, `this.on` and `this.after` registered before `await super.init()`
|
|
382
|
+
run around the pipeline; an `on('READ', …)` handler runs instead of it unless it calls `next()`.
|
|
383
|
+
- **CAP can construct it too.** The plugin registers `kind: 'soap'` with CAP (`cds.requires.kinds.soap`), so a
|
|
384
|
+
`cds.connect.to('<Service>')` before the plugin started the service, or a mocked service under `cds watch`, works.
|
|
385
|
+
- **CAP serves it as well.** A SOAP service of the model is also an OData service of the application
|
|
386
|
+
(`/odata/v4/<path>`). Reads there run the same pipeline, writes answer 405. When CAP served it with a class of its own
|
|
387
|
+
(the adapter class named by `@Soap.adapter` or `impl`, which CAP does not read), the plugin registers the adapter class
|
|
388
|
+
instead and CAP's endpoint hands every request to it.
|
|
347
389
|
|
|
348
|
-
|
|
390
|
+
### Service properties
|
|
391
|
+
|
|
392
|
+
Available inside `init()` and the hooks via `this.*` (CAP's service API):
|
|
349
393
|
|
|
350
394
|
| Property | Type | Description |
|
|
351
395
|
| -------- | ---- | ----------- |
|
|
396
|
+
| `this.name` | `string` | The service name. |
|
|
352
397
|
| `this.entities` | `Record<string, Csn.Entity>` | CSN entity definitions for the service, keyed by entity short name. |
|
|
353
|
-
| `this.model` | `Csn.Model` | Full CSN model
|
|
354
|
-
| `this.options` | `object` |
|
|
398
|
+
| `this.model` | `Csn.Model` | Full CSN model. |
|
|
399
|
+
| `this.options` | `object` | The service's `cds.requires.<ServiceName>` entry (wsdl path, credentials, …). |
|
|
400
|
+
|
|
401
|
+
### Migrating from 0.3
|
|
402
|
+
|
|
403
|
+
0.3 constructed `soap.ApplicationService` per request. Since 0.4 the class is the service:
|
|
404
|
+
|
|
405
|
+
| 0.3 | 0.4 |
|
|
406
|
+
| --- | --- |
|
|
407
|
+
| `class BP extends soap.ApplicationService` | `class BP extends soap.Service` |
|
|
408
|
+
| `init() { … }` | `async init() { …; await super.init(); }` |
|
|
409
|
+
| `this.req`, `this.entity`, `this.traceContext` | the `req` argument of the hook (`req.target`, `req.context.id`) |
|
|
410
|
+
| `@Soap.adapter` on an entity | `@Soap.adapter` on the service, or `cds.requires.<Service>.impl` |
|
|
411
|
+
| `soap.ApplicationService.stripSensitiveHeaders` / `.FORBIDDEN_SOAP_HEADERS` | `soap.Service.stripSensitiveHeaders` / `.FORBIDDEN_SOAP_HEADERS` |
|
|
412
|
+
|
|
413
|
+
A class that does not extend `soap.Service` stops the start with `the adapter class of SOAP service "<Service>" (<file>)
|
|
414
|
+
does not extend soap.Service`, followed by these steps. An `init()` that does not call `await super.init()` stops the
|
|
415
|
+
start as well (`init() of the adapter class of SOAP service "<Service>" (<file>) does not call "await super.init()"`):
|
|
416
|
+
without it the service has no READ pipeline.
|
|
355
417
|
|
|
356
418
|
### `this.header(entity, fn)` — outbound HTTP headers
|
|
357
419
|
|
|
@@ -373,7 +435,9 @@ this.header(BusinessPartner, () => ({
|
|
|
373
435
|
- Return value: a plain map, or a structured `{ value, name?, prefix?, xmlns? }`. Returning `null` / `undefined` adds nothing.
|
|
374
436
|
- 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.
|
|
375
437
|
- Without any `header()` hook the runtime adds one default SOAP header, `TraceID`, with the request's correlation id.
|
|
376
|
-
- Called once per SOAP dispatch (including each fan-out call for `multipleSelection: false`).
|
|
438
|
+
- Called once per SOAP dispatch (including each fan-out call for `multipleSelection: false`). Every hook of a dispatch
|
|
439
|
+
runs before its headers are set on the (shared) SOAP client and the call starts, so concurrent calls never mix their
|
|
440
|
+
headers.
|
|
377
441
|
|
|
378
442
|
### `this.request(entity, fn)` — outbound SOAP payload
|
|
379
443
|
|
|
@@ -456,14 +520,14 @@ this.response(BusinessPartner, (req, rows) => {
|
|
|
456
520
|
- Several `response()` hooks run in registration order, each receiving the previous result. Always return an array:
|
|
457
521
|
a hook that returns `undefined` yields an empty result.
|
|
458
522
|
|
|
459
|
-
### `soap.
|
|
523
|
+
### `soap.Service.stripSensitiveHeaders(headers)` — static helper
|
|
460
524
|
|
|
461
525
|
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:
|
|
462
526
|
|
|
463
527
|
```js
|
|
464
528
|
this.header(BusinessPartner, (req) => ({
|
|
465
529
|
// Everything from the inbound request except credentials, cookies and trace ids.
|
|
466
|
-
...soap.
|
|
530
|
+
...soap.Service.stripSensitiveHeaders(req.http?.req?.headers ?? {}),
|
|
467
531
|
TraceID: req.id
|
|
468
532
|
}));
|
|
469
533
|
```
|
|
@@ -497,17 +561,17 @@ Removed header names (case-insensitive):
|
|
|
497
561
|
| `sec-websocket-key` | WebSocket |
|
|
498
562
|
| `sec-websocket-accept` | WebSocket |
|
|
499
563
|
|
|
500
|
-
The full list is exposed as the frozen constant `soap.
|
|
564
|
+
The full list is exposed as the frozen constant `soap.Service.FORBIDDEN_SOAP_HEADERS` (a `readonly string[]`). Consumers can inspect or extend it:
|
|
501
565
|
|
|
502
566
|
```js
|
|
503
567
|
const { soap } = require('@cap-ts/soap-adapter');
|
|
504
568
|
|
|
505
569
|
// Inspect the built-in list
|
|
506
|
-
console.log(soap.
|
|
570
|
+
console.log(soap.Service.FORBIDDEN_SOAP_HEADERS);
|
|
507
571
|
|
|
508
572
|
// Extend with your own headers (create a new Set — do not mutate the frozen array)
|
|
509
573
|
const myForbidden = new Set([
|
|
510
|
-
...soap.
|
|
574
|
+
...soap.Service.FORBIDDEN_SOAP_HEADERS,
|
|
511
575
|
'x-my-internal-token',
|
|
512
576
|
]);
|
|
513
577
|
const withoutForbidden = (headers) =>
|
|
@@ -522,7 +586,7 @@ this.header(BusinessPartner, (req) => withoutForbidden(req.http?.req?.headers ??
|
|
|
522
586
|
|
|
523
587
|
[↑ Table of Contents](#-table-of-contents)
|
|
524
588
|
|
|
525
|
-
Beyond `
|
|
589
|
+
Beyond `Service`, 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.
|
|
526
590
|
|
|
527
591
|
```js
|
|
528
592
|
const { soap } = require('@cap-ts/soap-adapter');
|
|
@@ -567,7 +631,7 @@ One-shot SOAP read: assemble headers, build a real `cds.Request`, dispatch, and
|
|
|
567
631
|
|
|
568
632
|
```js
|
|
569
633
|
const rows = await soap.read(
|
|
570
|
-
srv, //
|
|
634
|
+
srv, // the SOAP service, e.g. cds.services.BP
|
|
571
635
|
'BusinessPartner', // entity short name (key into srv.entities)
|
|
572
636
|
req, // inbound CAP request (source of user, params, headers)
|
|
573
637
|
req.query, // CQN SELECT query
|
|
@@ -582,7 +646,7 @@ const rows = await soap.read(
|
|
|
582
646
|
|
|
583
647
|
| Parameter | Type | Required | Description |
|
|
584
648
|
| --------- | ---- | -------- | ----------- |
|
|
585
|
-
| `srv` | `
|
|
649
|
+
| `srv` | `soap.SoapService` | Yes | The SOAP service (`cds.services.<Name>`, an instance of its adapter class). |
|
|
586
650
|
| `entity` | `string` | Yes | Unqualified entity short name, e.g. `'BusinessPartner'`. |
|
|
587
651
|
| `req` | `cds.Request` | Yes | Source of `user`, `params`, and `headers`. |
|
|
588
652
|
| `query` | `object` | Yes | CQN SELECT query passed to `createRequest`. |
|
|
@@ -649,14 +713,17 @@ Everything the package exposes, values and types, lives under the single `soap`
|
|
|
649
713
|
import { soap } from '@cap-ts/soap-adapter';
|
|
650
714
|
|
|
651
715
|
const opts: soap.ReadOptions = { headers: {} };
|
|
652
|
-
class BP extends soap.
|
|
653
|
-
init(): void {
|
|
716
|
+
class BP extends soap.Service {
|
|
717
|
+
async init(): Promise<void> {
|
|
654
718
|
this.header('BusinessPartner', (req): soap.SoapHeaderObject => ({ value: {} }));
|
|
719
|
+
await super.init();
|
|
655
720
|
}
|
|
656
721
|
}
|
|
657
722
|
```
|
|
658
723
|
|
|
659
|
-
Available types: `soap.ReadOptions`, `soap.SoapService`, `soap.CdsEntityDef`, `soap.CsnEntityDefinition`, `soap.CreateRequestOptions`, `soap.CdsEntity`, `soap.CdsEntityElement`, `soap.SoapBindingConfig`, `soap.SoapHeaderObject`, `soap.
|
|
724
|
+
Available types: `soap.ReadOptions`, `soap.SoapService`, `soap.CdsEntityDef`, `soap.CsnEntityDefinition`, `soap.CreateRequestOptions`, `soap.CdsEntity`, `soap.CdsEntityElement`, `soap.SoapBindingConfig`, `soap.SoapHeaderObject`, `soap.HookTarget` (what a hook is registered for: an entity name, or an
|
|
725
|
+
object with a `name` such as an entity of `this.entities`). `soap.Service` brings CAP's service types (`this.on`,
|
|
726
|
+
`this.entities`, ...) from `@cap-js/cds-types`. There is no top-level export besides `soap`.
|
|
660
727
|
|
|
661
728
|
---
|
|
662
729
|
|
|
@@ -706,6 +773,8 @@ Per-service configuration lives in `cds.requires.<ServiceName>`:
|
|
|
706
773
|
| `wsdl` | `string` | **Yes** | Path to the WSDL file, relative to `cds.root`. |
|
|
707
774
|
| `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`. |
|
|
708
775
|
| `credentials.path` | `string` | No | Path appended to the destination URL to form the SOAP endpoint. |
|
|
776
|
+
| `impl` | `string` | No | Module of the adapter class: a path relative to the project root, or a package. See [Finding the adapter class](#finding-the-adapter-class). |
|
|
777
|
+
| `strict` | `boolean` | No | `true`: a missing adapter class stops the start instead of a warning and 501 answers. |
|
|
709
778
|
|
|
710
779
|
The service is looked up in `cds.requires` by its **short name** (the last segment of the service name).
|
|
711
780
|
|
|
@@ -816,7 +885,7 @@ GET /soap/fi/FinancialDocuments?$filter=FiscalYear eq '2025'
|
|
|
816
885
|
|
|
817
886
|
| Value | `$filter` clause | Runtime behaviour |
|
|
818
887
|
| ----- | ---------------- | ----------------- |
|
|
819
|
-
| `false` | `BusinessPartnerID in ('BP001','BP002')` | Expands to 2 SOAP calls (one per value, run in parallel); results merged. |
|
|
888
|
+
| `false` | `BusinessPartnerID in ('BP001','BP002')` | Expands to 2 SOAP calls (one per value, run in parallel, each in a transaction of its own); results merged, then `$orderby`, `$select`, `$top` / `$skip` (and the page cap) and `$count` apply to the merged rows. |
|
|
820
889
|
| `false` | `BusinessPartnerID in ('BP001') or Name eq 'x'`, or two `in(…)` on the key | Rejected with HTTP 400 `UNSUPPORTED_MULTI_SELECTION_FILTER`. |
|
|
821
890
|
| `false` | `BusinessPartnerID eq 'BP001'` | Single SOAP call. |
|
|
822
891
|
| `true` / not set | any | Single SOAP call; the `request()` hook handles `in(…)` itself (or the in-memory evaluator filters the result). |
|
|
@@ -845,9 +914,9 @@ Responses are `application/json;odata.metadata=minimal`. Only GET is mounted; wr
|
|
|
845
914
|
| --- | --- |
|
|
846
915
|
| `$filter` | Parsed by a fail-closed parser into CQN: `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `and`, `or`, `not`, `in (...)`, parentheses, string, number (also negative), ISO date-time, boolean and `null` literals, and the functions `startswith`, `endswith`, `contains`, `tolower`, `toupper`, `length`, `concat`, `substring`. Anything else (unknown characters or functions, a function on the left of a comparison, GUID literals, unterminated strings) is a 400, never ignored. `'It''s'` keeps the doubled quote. |
|
|
847
916
|
| `$select` | Projection of the answer. |
|
|
848
|
-
| `$orderby`, `$top`, `$skip` | Applied in memory unless the adapter pushed them down (`__filterPushedDown`). |
|
|
849
|
-
| `$skiptoken` | Treated as `$skip` (so `@odata.nextLink` values work). |
|
|
850
|
-
| `$count=true` | `@odata.count`: the total before paging. |
|
|
917
|
+
| `$orderby`, `$top`, `$skip` | Applied in memory unless the adapter pushed them down (`__filterPushedDown`). Without `$top` the answer is cut to `cds.query.soap.pageSize` rows and `@odata.nextLink` continues the same query (`$filter`, `$orderby`, `$select`, `$count` repeated, `$skiptoken` advanced); a client `$top` up to the maximum page size (5000) is answered without a next link, one above it gets a link with the rest of its `$top`. |
|
|
918
|
+
| `$skiptoken` | Treated as `$skip` (so `@odata.nextLink` values work); without `$top` the next page has the page size of `cds.query.soap.pageSize`, like the first. |
|
|
919
|
+
| `$count=true` | `@odata.count`: the total before paging. The query the hooks and handlers see carries it (`req.query.SELECT.count`), so a `request()` hook can decide not to push the paging (see [`__filterPushedDown`](#__filterpusheddown-sentinel)). |
|
|
851
920
|
| `$search` | Accepted and ignored. |
|
|
852
921
|
| `$expand`, `$apply`, `$format`, any other `$` option | 400 `The system query option "<x>" is not supported by soap endpoint.` |
|
|
853
922
|
|
|
@@ -964,7 +1033,8 @@ Error bodies are `{ "error": { "code", "message", "@Common.numericSeverity": 4 }
|
|
|
964
1033
|
| `@requires` set and no authenticated user / user without the role | 401 / 403 |
|
|
965
1034
|
| Read by key without a row | 404 `Entity instance not found.` |
|
|
966
1035
|
| CREATE / UPDATE / DELETE | 405 |
|
|
967
|
-
| No adapter class
|
|
1036
|
+
| No adapter class found for the service | 501 `No adapter class for SOAP service "<Service>" (entity "<Entity>"): see the "[soap] no adapter class" warning in the startup log` |
|
|
1037
|
+
| A hook rejects with `req.reject(<status>, <message>)` | that status; the message for 4xx and 501, `SOAP integration call failed` for other 5xx |
|
|
968
1038
|
| SOAP fault, network or destination error, any other failure | 500 `SOAP integration call failed` (details only in the redacted log) |
|
|
969
1039
|
| One fan-out call fails | the whole request fails |
|
|
970
1040
|
| Empty answer | `value: []` (`@odata.count: 0` with `$count`) |
|
|
@@ -988,13 +1058,15 @@ grace period is over. Contact SAP-Code-World for a new token. See [Licence](#lic
|
|
|
988
1058
|
|
|
989
1059
|
**Symptom:** `GET /soap/bp/BusinessPartner` returns `404` (no route) or `501` (no adapter class).
|
|
990
1060
|
|
|
991
|
-
**Cause:** The loader skips entities without `@Soap.operation`, and a request fails with 501 when no adapter class was found for the
|
|
1061
|
+
**Cause:** The loader skips entities without `@Soap.operation`, and a request fails with 501 when no adapter class was found for the service. The startup log says what was searched (`[soap] no adapter class for <Service>; tried: …`), and the boot line shows `impl: 'undefined'`. Check that:
|
|
992
1062
|
|
|
993
1063
|
1. The entity has `@Soap.operation: '<WsdlOperationName>'` in its `.cds` model.
|
|
994
1064
|
2. The service is declared with `kind: "soap"` in `cds.requires`.
|
|
995
1065
|
3. The `.cds` file is reachable from `cds.model` (i.e., it is `using`-imported somewhere in the model graph).
|
|
996
1066
|
4. The URL uses the service's `@path`, or its short name in lower case.
|
|
997
|
-
5. The adapter class resolves (see [Adapter class not loaded](#adapter-class-not-
|
|
1067
|
+
5. The adapter class resolves (see [Adapter class not found or not loaded](#adapter-class-not-found-or-not-loaded)). An
|
|
1068
|
+
application served from a build output (`gen/srv`) needs the compiled `.js` file in one of the searched folders of
|
|
1069
|
+
the compiled `.cds` folder structure, or `impl` / `@Soap.adapter` pointing to it.
|
|
998
1070
|
|
|
999
1071
|
---
|
|
1000
1072
|
|
|
@@ -1016,7 +1088,7 @@ grace period is over. Contact SAP-Code-World for a new token. See [Licence](#lic
|
|
|
1016
1088
|
|
|
1017
1089
|
**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.
|
|
1018
1090
|
|
|
1019
|
-
**Fix:** Read
|
|
1091
|
+
**Fix:** Read the binding after `cds.on('served', …)`, e.g. from `this.entities` inside a hook (at request time).
|
|
1020
1092
|
|
|
1021
1093
|
---
|
|
1022
1094
|
|
|
@@ -1032,13 +1104,16 @@ node -e "const cds = require('@sap/cds'); console.log(cds.root)"
|
|
|
1032
1104
|
|
|
1033
1105
|
---
|
|
1034
1106
|
|
|
1035
|
-
### Adapter class not
|
|
1107
|
+
### Adapter class not found or not loaded
|
|
1036
1108
|
|
|
1037
|
-
**Symptom:** Boot logs `adapter
|
|
1109
|
+
**Symptom:** Boot logs `[soap] no adapter class for <Service>`, `adapter "…" of @Soap.adapter could not be resolved`,
|
|
1110
|
+
`Failed to load adapter at "…"`, or stops with `… does not extend soap.Service`.
|
|
1038
1111
|
|
|
1039
1112
|
**Fix checklist:**
|
|
1040
1113
|
|
|
1041
|
-
1. The module
|
|
1114
|
+
1. The module must export the class as `module.exports` or `default` (not `exports.MyClass`), the class must
|
|
1115
|
+
extend `soap.Service`, and its `init()` must end with `await super.init()` (see
|
|
1116
|
+
[Migrating from 0.3](#migrating-from-03)).
|
|
1042
1117
|
2. If using TypeScript, something must load `.ts`: `cds watch` / `cds-tsx`, `tsx`, Node 22.18 or later, or `ts-node`
|
|
1043
1118
|
(`npm i -D ts-node`). The `Failed to load adapter` error of a `.ts` adapter lists these options.
|
|
1044
1119
|
3. For monorepo sibling packages, ensure the package is listed in `dependencies` and `npm install` has run.
|
package/index.cds
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
annotation Soap : Boolean
|
|
1
|
+
annotation Soap : Boolean;
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Binds an entity to a concrete SOAP operation.
|
|
@@ -53,13 +53,12 @@ annotation Soap.filterRestriction {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* `<cds-dir>/lib/<ServiceShortName>.{ts,js}`.
|
|
56
|
+
* Service only: the module of the service's adapter class (a subclass of
|
|
57
|
+
* `soap.Service`), as a path relative to the project root (with or without
|
|
58
|
+
* `./`, without extension) or a package specifier resolved from the project
|
|
59
|
+
* root. It comes before `cds.requires.<Service>.impl` and the file
|
|
60
|
+
* convention `<Service>.{ts,js}` in the folder of the service's `.cds` file
|
|
61
|
+
* (`@source` of a compiled model), its `lib/` or its `handlers/` folder.
|
|
62
|
+
* On an entity it stops the start: the adapter class is the service.
|
|
64
63
|
*/
|
|
65
64
|
annotation Soap.adapter : String;
|