@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 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 hooks | `header()` (SOAP headers), `request()` (input message, push-down sentinels), `response()` (raw rows), `mock()` (offline) |
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.ApplicationService`)](#-adapter-class-api-soapapplicationservice)\
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.ApplicationService) │
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 subclass (of soap.ApplicationService)
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 an adapter subclass at `srv/external/lib/BP.js`.**
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.ApplicationService {
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: '<npm specifier>'` | entity / service | No | Adapter module, instead of the `lib/<Service>.{ts,js}` convention. |
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. `@Soap.path` is essential when SOAP responses nest values several levels deep or when element names differ from the response.
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, entity- or service-level)* — optional
303
+ ### `@Soap.adapter` *(scalar, service-level)* — optional
300
304
 
301
- npm specifier of the adapter module implementing the request / response / mock / header hooks. Resolved at boot.
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' // sibling package
309
+ @Soap.adapter : '@my-org/bp-adapter' // sibling package
305
310
  service BP { … }
306
311
 
307
- // or, per-entity override:
308
- @Soap.adapter : '@my-org/bp-adapter/dist/BusinessPartner'
309
- entity BusinessPartner { … }
312
+ @Soap.adapter : 'srv/adapters/BusinessPartner' // <root>/srv/adapters/BusinessPartner.ts, else .js
313
+ service BP2 { … }
310
314
  ```
311
315
 
312
- #### Resolution order
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
- 1. Entity-level `@Soap.adapter` (if set).
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
- 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.
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
- Use `@Soap.adapter` when:
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
- - The adapter class is shipped from a sibling package in a monorepo.
323
- - The `.cds` is pre-compiled and `$location.file` is unavailable.
324
- - You want to compose adapters across packages, or override the convention per entity.
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.ApplicationService`)
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.ApplicationService`. The class is instantiated once per service at boot and its `init()` method is called to register per-entity hooks.
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.ApplicationService {
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
- ### Constructor properties
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
- Available inside `init()` via `this.*`:
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 for the service. |
354
- | `this.options` | `object` | Merged `cds.requires.<ServiceName>` config object (wsdl path, credentials, …). |
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.ApplicationService.stripSensitiveHeaders(headers)` — static helper
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.ApplicationService.stripSensitiveHeaders(req.http?.req?.headers ?? {}),
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.ApplicationService.FORBIDDEN_SOAP_HEADERS` (a `readonly string[]`). Consumers can inspect or extend it:
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.ApplicationService.FORBIDDEN_SOAP_HEADERS);
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.ApplicationService.FORBIDDEN_SOAP_HEADERS,
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 `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.
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, // ApplicationService instance
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` | `ApplicationService` | Yes | The SOAP adapter instance. |
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.ApplicationService {
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.TraceContext`, `soap.BaseAdapterInstance`. There is no top-level export besides `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 resolved for the entity | 501 |
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 entity. Check that:
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-loaded--typeerror--is-not-a-constructor)).
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 binding via the loader API (available after `cds.on('served', …)`), or use the post-loader entity definition from `this.entities` inside `init()`.
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 loaded / `TypeError: … is not a constructor`
1107
+ ### Adapter class not found or not loaded
1036
1108
 
1037
- **Symptom:** Boot logs `adapter specifier … could not be resolved` or throws on instantiation.
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 at the resolved path must `module.exports` the class (not `exports.MyClass`).
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 default true;
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
- * npm specifier of the adapter module that implements the request /
57
- * response / mock / header hooks for this service or entity. Resolved via
58
- * `require.resolve(spec, { paths: [cds.root] })` — unblocks monorepo reuse
59
- * (adapter class shipped from a sibling package) and works with pre-
60
- * compiled CSN where `$location.file` is unavailable. When set on both an
61
- * entity and its parent service, the entity-level value wins. When
62
- * unset, the loader falls back to the filesystem convention
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-ts/soap-adapter",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "SOAP Adapter for CAP",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",