@cap-ts/remote-service-adapter
v0.2.6
Published
Remote service Adapter for CAP
Maintainers
Readme
@cap-ts/remote-service-adapter
A CAP service implementation that turns CDS projections and views into working OData endpoints over remote backends (read, write, actions), without per-entity handler code.
@sap/cds^9.9.1 or ^10, plain CommonJS output, TypeScript typings included.
📦 About
@cap-ts/remote-service-adapter exports one class, RemoteApplicationService. Make it the implementation of a CDS
service (annotate the service @remote, or extend the class) and every entity of that service is served from the
entity's source: a remote OData V2 / V4 service, a SOAP service (via @cap-ts/soap-adapter), another CAP service of the
same application, or the database. The service reads the mapping from the projection itself (aliases, calculated
columns, association paths, structured fields, the projection's where) and translates every request to the backend's
names and every answer back.
What you get without writing a handler:
| Area | Features |
| --- | --- |
| Reading | Hybrid $filter push-down, field aliases, calculated columns, association path columns, structured fields, the projection's own where, $expand across services, $search, $orderby, paging, $count, DISTINCT / GROUP BY / aggregates, JOIN views, static data |
| Filters through associations | _A/_B/Field eq 'v' and OData any() through to-one and to-many associations, answered by key semi-joins instead of full reads |
| Reads by key | A filter that pins the source key (or lists several keys) becomes one read by key per key; unknown keys answer 404 |
| Writing | CREATE / UPDATE / DELETE on the projection written to its source; write rules checked before the backend is written; deep writes; non-CRUD APIs (every change a POST) |
| Operations | Bound and unbound actions / functions forwarded to the source operation |
| Backend quirks | Opt-in annotations for fields a backend cannot filter, flags it cannot compare, date-time fields exposed as dates |
| Operations support | [remote] log line of every backend request, request-scoped tracing, secret redaction |
It has no authentication or authorization model of its own (CAP's @requires / @restrict apply as usual), no
business logic and no override seams: bespoke behavior goes into ordinary CAP handlers of your service.
📑 Table of Contents
📥 Installation
🧭 How it works
⚡ Quick start
🏷️ Annotation reference
📝 Modelling reference
🔍 Reading data
🔗 Filters through associations
🔑 Reads by key
🧠 Caching
✏️ Writing data
⚙️ Actions and functions
🔌 Class API
🧩 CAP plugin: the @remote annotation
🧼 SOAP backends
🛠️ Configuration and logging
🚦 Errors and results
🚀 Performance guide
🔒 Boundaries and security
🩺 Troubleshooting
⚠️ Known limitations
💬 Support and feedback
📄 License
📥 Installation
npm install @cap-ts/remote-service-adapterThe package registers itself as a CAP plugin (cds-plugin.js): no require() is needed for the @remote annotation.
cds watch and cds build pick it up. Importing the class (import { RemoteApplicationService } from
'@cap-ts/remote-service-adapter') works either way.
| Peer | Version | Notes |
| --- | --- | --- |
| @sap/cds | ^9.9.1 \|\| ^10 | CAP runtime. Node.js >= 20 (cds 10 needs >= 22). |
| @cap-ts/soap-adapter | ^0.4.0 | Used at runtime only for services of kind: 'soap'; a required peer because the SOAP dispatch imports it. |
| @sap-cloud-sdk/connectivity, @sap-cloud-sdk/http-client | ^4.7.0 | Destination lookup and HTTP for @cap-ts/soap-adapter. |
| @cap-js/cds-types | >=0.18.0, optional | TypeScript types for @sap/cds. |
No runtime dependencies of its own (everything else is a peer or a Node.js built-in).
🧭 How it works
Client (OData V4 / Fiori)
│
▼
Your CAP service (implementation: RemoteApplicationService, via @remote or a subclass)
│ READ / CREATE / UPDATE / DELETE / actions of every entity
▼
RemoteApplicationService: query in local names → query in source names → backend → rows in local names
│ cds.connect.to() / a service of this application / cds.db / soap.read()
▼
Backends: OData V2 / V4, SOAP (@cap-ts/soap-adapter), other CAP services of this application, the databaseFor every request the service:
- Splits the
$filterinto the part the backend can evaluate (pushed) and the rest (evaluated in memory). - Translates the pushed part,
$select,$orderby, keys and paging to the source's names. - Sends the query to the service that owns the source entity (paging through the backend when it needs every row).
- Maps the answer back: aliases, calculated columns, structured fields, association paths,
$expand. - Applies what was not pushed (filter,
$search, sort, paging,$count) and prunes to the requested shape.
Services can be stacked: a projection on an entity of another @remote service is served the same way, and each layer
pushes down what it can to the layer below.
⚡ Quick start
1. Configure the external service (package.json or .cdsrc.json):
{
"cds": {
"requires": {
"RemoteOrders": {
"kind": "odata-v4",
"model": "srv/external/RemoteOrders",
"credentials": { "destination": "ORDERS_DESTINATION" }
}
}
}
}kind is odata, odata-v2, odata-v4 or soap. It decides the $search case rule and, for soap, the dispatch.
2. Declare the projection service, annotated @remote:
using { RemoteOrders } from './external/RemoteOrders';
@remote
service OrderService {
entity Orders as projection on RemoteOrders.Orders {
key ID,
OrderName as Title, // alias: local Title <-> remote OrderName
Amount, // same name
_Customer.Name as CustomerName, // to-one association path
_Items // association, for $expand
};
}3. Run cds watch. GET /odata/v4/order/Orders is live: no handler file.
Alternative: extend the class, when the service also needs handlers of its own:
// srv/OrderService.ts
import { RemoteApplicationService } from '@cap-ts/remote-service-adapter';
export class OrderService extends RemoteApplicationService {
async init(): Promise<void> {
// Handlers registered here run BEFORE the generic ones (READ, writes, operations of every entity).
this.before('CREATE', 'Orders', (req) => { /* validation, derived values */ });
await super.init();
}
}With a handler file (or @impl, or cds.requires.<Service>.impl), the plugin leaves the service alone.
🏷️ Annotation reference
Every annotation goes on the definition it describes, in the local (projection) service.
| Annotation | On | Effect | Section |
| --- | --- | --- | --- |
| @remote | service | Makes RemoteApplicationService the service's implementation (CAP plugin). | CAP plugin |
| @response.data: [ {...} ] | entity | Static rows: returned as they are, no backend call. Not writable. | Static data |
| @remote.paging: false | entity / service | A read of every row of a remote source (in-memory filter, no limit) is one request, as before this package paged such reads. For a backend that can not page. | Reading data |
| @remote.filter.local | element / entity | Request $filter and $search on the element run in memory. On the entity: every element whose source field is @sap.filterable: 'false'; @remote.filter.local: false on an element opts it out. | Fields the backend cannot filter |
| @remote.cache: true / false, @remote.cache.ttl: <seconds> (@remote.cache: { ttl: 60 }) | entity / service | Opt-in cache of reads by key of the entity (service: all its entities); false on an entity opts out. For a GROUP BY / DISTINCT entity it also enables the aggregate cache. | Caching |
| @remote.warmUp, @remote.warmUp: { where, every, limit, enabled }, @remote.warmUp: [ {...}, ... ] | entity | Read the entity when the server starts and again before the caches the read filled expire, so users find them filled. where: a CDS expression or a string; one read per record. | Warm-up |
| @remote.search.maxRows: <n> | entity | Opt-in: a $search that matched more than n rows of this entity, whose association paths must be resolved, is refused (502) instead of read. | Reading data |
| @remote.pushdown: false | calculated element | Filters on the element stay in memory (no boolean-flag push-down). | Calculated columns |
| @remote.operation: 'Name' / '<Service>.<Name>' | action / function | Forwards to the bound operation Name of the source entity, or to the unbound operation of another service. | Actions and functions |
| @remote.write.asInsert: ['UPDATE', 'DELETE'] | entity | Sends these events as INSERT (POST) with the source keys in the body. | Non-CRUD backends |
| @remote.write.operation: { field, CREATE, UPDATE, DELETE } | entity | Sets the source field field to the event's code in the body. | Non-CRUD backends |
| @remote.write.truncate | entity / element | Cuts strings to the element's length before the write (false on an element opts out). | Writing data |
| @remote.write.deep | association | Data of the association is written with the parent (upsert per child row). | Deep writes |
| @remote.assert: (case when <cond> then '<message>' end) | element | Check evaluated before the backend write; the first matching when is the error. | Write rules |
| @remote.assert.args: [ ... ] | element | Message arguments ({0}, {1}, ...) of @remote.assert: paths or (<expression>). | Write rules |
| @remote.assert.target | to-one association | The target must exist (ASSERT_TARGET). | Write rules |
| @remote.write.value: (<expression>) | element | Value computed before the write when the data has none. | Write rules |
Standard annotations the service reads:
| Annotation | Effect |
| --- | --- |
| @cds.search: false / true (element), @cds.search: { A, B } (entity) | Which columns $search uses. |
| @cds.query.limit: { default, max } (source entity or its service) | Page size when every source row is read (GROUP BY, DISTINCT, association fetches). |
| @sap.filterable: 'false' (source element, from the EDMX) | Terms of the projection's own where on it are evaluated in memory; request filters with @remote.filter.local. |
| @odata.Type: 'Edm.Date' (local element) | A date-time source value is delivered as a date, date literals are compared as days. |
| @mandatory (child element) | Checked on every child row of a deep write (CAP checks the root only). |
| @readonly | CAP drops client values; your handler may set them (backend-only values). |
📝 Modelling reference
The local ↔ remote mapping comes from the projection's columns. Never hard-code remote field names in TypeScript.
entity Orders as projection on RemoteOrders.Orders {
key ID,
OrderName as Title, // alias
Amount, // same name
virtual Flag : Boolean, // never requested from the backend
left(OrderName, 2) as Prefix : String(2), // calculated in memory
case when Status = 'X' then true else false end as IsClosed : Boolean, // boolean flag, filter pushed
Address.City as City, // field of a structured element
_Customer.Name as CustomerName, // to-one association path, any depth
_Items, // association, $expand
_Customer : redirected to Customers // association to another entity of this service
} where Kind = 'A'; // the projection's own WHERE, applied to every read and write| Element | Behavior |
| --- | --- |
| Remote as Local | Translated both ways. A remote field exposed under several names (* plus Field as Alias) is requested once and fills every name. |
| virtual | Never requested; filters on it run in memory. |
| Calculated (<expr> as Name) | Computed in memory from the source values: a CASE, or one function call (left(Field, 2) reads the source's Field even when the projection renames it). Its source fields are requested automatically. Never sent to the backend. |
| Boolean flag (case when Field = 'v' then true else false end) | Computed in memory; a filter Flag = true / false is pushed as Field = 'v' / Field != 'v'. |
| exists flag (case when exists _Items then true else false end) | The association is read for the rows of the answer, then the flag is computed in memory. See Association flags (exists). |
| Structured field (Struct.Field as X) | The backend is asked for the structure, the answer flattened. Filters on it run in memory. Written back as { Struct: { Field } }. |
| Path column (_A._B.Field as X) | Resolved across to-one associations at any depth, one batched key fetch per hop. A to-many hop or filtered segment requested explicitly is 501. |
| Association | Declare the ON condition (or managed keys). Used for $expand, path columns, filters through associations, write rules and deep writes. |
| Date element over a date-time source field | Delivered as yyyy-mm-dd; date literals compared with it are sent as day ranges. |
| String(n) | A $search word longer than n is not pushed for that column. |
Static data
@response.data: [{ ID: 'A', Name: 'Alpha' }, { ID: 'B', Name: 'Beta' }]
entity Codes as projection on RemoteOrders.Codes { key ID, Name };Returned without a backend call, never written. A request $filter (and $count) is evaluated on the rows in memory, like any
filter on a projection that cannot be pushed.
JOIN views
entity OrderLines as select from RemoteOrders.Orders as o
join RemoteOrders.Items as i on i.OrderID = o.ID and i.Status = 'Active'
left join RemoteCustomers.Customers as c on c.ID = o.Customer {
key i.ID, o.Title, i.Quantity, c.Name as CustomerName, i.Quantity * i.Price as Total : Decimal(15, 2)
} where o.Kind = 'STD';Sources of any kind (remote OData, SOAP, CAP-served, the database), inner and left joins, any number of them:
- The terms of the request's
$filter(and of the view's ownwhere) that read plain columns of one source are pushed to that source (not to the right side of a left join, not!=/not, not when the filter has a top-levelor), as are the constants an ON condition sets for the source it joins (i.Status = 'Active'). - The inner joins before the first left join start at their most restricted source (the key of a read by key first,
then the request's filter, the view's
where, ON constants):OrderLines('I1')reads the itemI1first, then its order by the join key. From the first left join on, the sources are read in the order of the FROM. Every next source is read by the join keys of the rows found so far (inlists of 200 values). - Each source contributes at most
cds.query.remote.join.maxRowsrows (default 5000, read in pages of its@cds.query.limit.max); more is a 502 asking for a$filter. - The rows are joined here (inner: every ON condition holds, constants included; left: a row without partner keeps its
columns
null), then the view's columns (plain columns and expressions), itswhere, the request's$filter,$search,$orderby,$count, paging,$expandand$selectapply. A read by key without row is 404. - 501:
right/full/crossjoins, a source joined without an equality to the others, a column or a filter that goes through an association,existsor a sub-select in awhere. JOIN views are not writable. - An application with a database compiles every view to SQL: annotate a JOIN view
@cds.persistence.skipwhen it joins a remote entity with one that has a table (an entity of the database, or of a SOAP service, which CAP deploys).
🔍 Reading data
$filter push-down
The WHERE is split at its top-level and. Each term is pushed to the backend when it only reads plain mapped fields;
otherwise it is evaluated in memory after the fetch. Evaluated in memory: virtual, calculated, path and unmapped
fields, structured fields, fields with @remote.filter.local, and (for SOAP) any function. The in-memory evaluator
understands and / or / not, comparisons, [not] in, is [not] null, exists (OData any()), and the functions
contains, startswith, endswith, indexof, tolower / toupper, length, trim, substring, concat,
replace, left, right, coalesce / ifnull, round, floor, ceiling, abs, year, month, day.
Pushed in a backend-friendly form:
| Request term | Sent as |
| --- | --- |
| Prefix = 'AB' on left(Field, 2) as Prefix | startswith(Field, 'AB') |
| IsClosed = true on case when Status = 'X' then true else false end as IsClosed | Status = 'X' (false: Status != 'X') |
| Day >= 2026-09-28 on a Date element over a date-time source | Day >= 2026-09-28T00:00:00Z (only >= / <=; = becomes the day range) |
| The same condition twice (e.g. from two stacked layers) | Once |
| A literal comparison that is false ('1' = '2', written by CAP for a restriction whose $user attribute is missing) | Nothing: the answer is empty, the backend is not called |
If the backend rejects the pushed filter (an error with 400 or filter in it, or an association path complaint),
the read is repeated without the filter and the whole WHERE is applied in memory.
A read whose filter is (partly) evaluated in memory needs every row the pushed part leaves: $top is not sent. Such a
read, and every other read of a remote source without a limit (for example the read of a projection on another entity
of this package, which asks that entity for every row), first asks for one page of the source's
@cds.query.limit.max (entity, then service, then cds.query.limit.max, else 1000) with the count, in the backend's
own order. When that is every row (the usual case) it is the answer. A longer answer is read again in pages ordered by
the source key, up to pagesInParallel at a time: a single request for every row can exceed what the backend answers
at once (S/4: HTTP 500 SYSTEM_NO_ROLL). One request without paging, as before: @remote.paging: false on the entity or
its service, cds.query.remote.paging: false, a source without key, or a backend that rejects the page request (400).
A filter or sort on an element that $select leaves out works, also on a calculated or path element (its source
fields are read): rows are filtered and sorted before they are pruned. A sort the backend does completely keeps the
backend's order.
A projection on an entity of another service of this package (a layered model) may rename its elements
(Item as ItemKey): columns, $filter, $orderby and keys go to that entity in its own names.
Fields the backend cannot filter
OData V2 services declare some fields sap:filterable="false" and ignore a $filter on them without an error (every
row comes back). Opt in to in-memory evaluation:
@remote.filter.local // every sap:filterable=false field of the source
entity Entries as projection on Remote.Entries {
*,
@remote.filter.local: false Note // ...except this one: pushed
};
entity Tasks as projection on Remote.Tasks {
key ID, Owner,
@remote.filter.local Label // just this element, whatever the EDMX says
};The read then fetches every row the rest of the filter leaves, so opt in where that set is small (for example when
another pushed term restricts it to one user's rows). Terms of the projection's own where on such fields are always
evaluated in memory, without annotation.
The projection's own WHERE
entity OpenOrders as projection on RemoteOrders.Orders {
key ID, Amount, _Customer.ValidTo as CustomerValidTo
} where Status != 'CANCELLED' and _Customer.ValidTo >= current_date;- Plain source fields are pushed with the request's filter (they need not be projected).
- To-one association paths are resolved and evaluated in memory.
$user,$user.<attribute>,$now,current_date,current_time,current_timestamptake the request's values.- Reads by key re-check every term in memory; a row that fails is 404. Projections on top inherit the filter.
- Writes respect it: CREATE fills
Field = 'v'terms (a different value is 400), UPDATE / DELETE of a row outside is 404. - What can be neither pushed nor evaluated (
exists, a sub-select,$at, a to-many or filtered path,like/between/ an unknown function in an in-memory term) is 501, never ignored.
Paging and $count
$top/$skipgo to the backend only when nothing is evaluated in memory afterwards; otherwise every row matching the pushed part is read and the page is cut here (logged once per entity as a warning oncds.log('remote')).$countis always a number. It is the backend's count when nothing is filtered in memory, else the count after local processing. OData V2 sends its count as text; the service converts it.- A count only (
$top=0&$count=true) is oneGET <Entity>/$count?$filter=...: no rows move. Fallbacks: one row plus the total, then reading in pages. A count never reads every row in one request. - When every source row is needed (GROUP BY, DISTINCT, association fetches of a stacked service), the source is read in
pages of its
@cds.query.limit.max(entity, then service, thencds.query.limit.max, CAP default 1000), ordered by the source key, until the backend's total is reached. A backend that caps its pages below that size just needs more pages. Ending short of the total is a 502, never a partial result. - A page past the end returns
[]with the full$count. $orderbywith$top/$skip: the backend sorts, so the page is the right one. The leading sort terms on plain elements are sent in the source's names (also CAP's implicit key order, which CAP adds to every paged OData request). A backend that rejects the sort (400) is asked again without it, and the page is sorted among itself. An explicit sort on a path or calculated element is applied here over every row, after one count request, for at most 5000 rows; above that the page is read as before and sorted among itself (logged oncds.log('remote')). CAP's implicit key order never causes a read of every row.- In-memory sorts compare numeric elements as numbers, also when the values arrive as strings.
$expand
- Declare the association;
$expand=_Itemsbecomes batched<key> in (...)reads of the target (at most 200 parent keys per request), grouped per parent. Nested expands,$filter,$orderbyand$countinside the expand work. - Children the backend delivered inline are mapped, filtered, limited and expanded further in place.
- A target served by another
RemoteApplicationServiceis read without a page limit, in pages up to its total. - A managed to-one association is joined on its foreign key elements (
<assoc>_<key>); an association from an EDMX model without ON condition joins on the key names it shares with its target. - A backend that rejects the expand query is asked again without columns, filter and order.
Path columns
_A._B.Field as X is resolved after the main read: one batched key fetch per hop level for all rows. To-many hops and
filtered segments are 501 when requested in $select, $filter or $orderby (left out otherwise). $orderby on a
path sorts by the resolved value.
Columns of a handler's query
A handler that reads through the service (this.run(SELECT ...)) can name path columns, aliases and expressions,
as with any CAP service:
const row = await this.run(SELECT.one(Orders).columns(
'Amount as Total', 'Customer.Name', 'Customer.City as Town', { func: 'upper', args: [{ ref: ['Customer', 'Name'] }], as: 'Loud' }
).where(key));
// { Total: 10, Customer_Name: 'Acme', Town: 'Berlin', Loud: 'ACME' }The source is read with plain columns: the elements these columns read, and an $expand of every association a path
goes along (for every source kind: database, CAP-served, OData, SOAP, and JOIN views). The answer has them under CAP's
names: the alias, else the path joined by _; null when the association has no target. Expressions are computed in
memory after the read: literals, elements, to-one paths, ||, + - * / (a null operand gives null), CASE and the
functions evaluated in memory (upper, lower, concat, substring, left, right, length, trim, replace,
coalesce, round, floor, ceiling, abs, year, month, day, contains, startswith, endswith, ...).
| Column form (query) | Database / CAP-served source | Remote OData / SOAP source |
| --- | --- | --- |
| Element Field | Supported | Supported |
| * | Supported | Supported |
| Path along to-one associations Assoc.Field, A.B.Field | Supported, as Assoc_Field | Supported, as Assoc_Field |
| Path with an alias Assoc.Field as X | Supported, as X | Supported, as X |
| Alias on an own element Field as X | Supported, as X | Supported, as X |
| expand: { ref: ['Assoc'], expand: [...] }, with or without alias | Supported, nested object / array | Supported, nested object / array |
| Expression with alias: upper(Field) as X, A * B as X, A || '-' || B as X, CASE, 1 as One, paths inside | Computed in memory | Computed in memory |
| Aggregates (count, sum, avg, min, max) | Handled by GROUP BY (see DISTINCT and GROUP BY) | Same |
| Path over a to-many association, with an infix filter, ending on an association or structure | 501 naming the column | 501 naming the column |
| inline: Assoc.{ Field } | 501 (name each path instead) | 501 (name each path instead) |
| Expression without alias; a function or operator not listed above (comparison outside a CASE, exists, a sub-select); an expression as a function argument; $user / $now | 501 naming the column | 501 naming the column |
Path columns of the query are 501 on DISTINCT / GROUP BY projections (aliases and expressions are computed on the aggregated rows).
Calculated columns
Computed in memory on the source values: a CASE expression (simple or searched; conditions with comparisons, and /
or / not, in, is null), or one function call (left(Field, 2), concat(A, B), coalesce(A, B), any function
listed above, also inside an expression). Operators outside a CASE (A || B, A + B) are not computed: write
concat(A, B), or compute the value in a handler. A calculated element of an association target served by another RemoteApplicationService is computed by that service.
An association whose ON condition uses a calculated column is resolved with the computed value.
@remote.pushdown: false keeps filters on a calculated column in memory, for a backend that mishandles the comparison
of its source field (for example Status != 'X').
A calculated column that reads a path across a to-many association (coalesce(_Items.ID, 'none')) is 501 when
requested in $select, $filter or $orderby: test the association with exists instead.
Association flags (exists)
@remote
service OrderService {
@readonly entity Orders as projection on RemoteOrders.Orders {
*,
_Items : Association to many Items on _Items.OrderID = $self.ID and _Items.Status = 'Active'
};
@readonly entity Items as projection on RemoteOrders.Items;
}
@remote
service OrderOverview {
@readonly entity Orders as projection on OrderService.Orders {
*,
case when exists _Items then true else false end as HasActiveItems : Boolean,
case when exists _Items[Quantity > 10] then true else false end as HasLargeItems : Boolean,
exists _Items._Product[Blocked = true] as HasBlockedProduct : Boolean
};
}exists <association>is true when the association has at least one row. Constant terms of its ON condition (_Items.Status = 'Active') and infix filters ([Quantity > 10]) apply. Paths of any depth work, through to-one and to-many associations.- Each association is read once for all rows of the answer, in batched key reads (one per hop and 200 parents), and only when the query asks for the flag.
$filterand$orderbyon the flag are evaluated in memory, on every row the pushed part of the filter leaves.- A flag no filter or sort needs is computed after the page is cut: for the rows of the answer only.
- A read that computes a flag for more than 5000 rows is refused (502, "narrow it down with
$filteror$search"): the association of every row would be read. A read of every row (a filter on the flag, or a layer above that filters on it) is counted first, so it fails in about one request; with$searchthe rows the search leaves are checked. A filterFlag = trueis answered by a semi-join instead (Filters through associations), so a projectionwhere HasActiveItems = trueover tens of thousands of orders works when the association's target is small enough forcds.query.remote.semiJoin.maxRows(with the key set cache, every later request answers from memory). The flag of the rows such a filter leaves istruewithout reading the association again, and the 5000-row limit does not apply to it. - A
$expandof the same association keeps its own$select,$filterand$top. - The association may also be one of the source only, when the projection lists its columns and leaves it out
(
entity ActiveOrders as projection on OrderService.Orders { key ID, case when exists _Items then true else false end as HasActiveItems : Boolean }). - 501 when the query names the flag and it cannot be computed: the association is an element of neither the projection nor its source,
an infix filter reads a path or contains
exists,existson a sub-select or inside a function call, or a DISTINCT / GROUP BY projection. A read without$selectleaves such a flag out.
Structured fields
Address.City as City asks the backend for Address and flattens the answer. Filters, $search and the left()
push-down on structured fields run in memory. Writes send { Address: { City } }.
$search
OData V4 syntax: words ANDed, OR, NOT, parentheses, "phrases". Searched: string elements (keys included), to-one
path strings and calculated string columns; not LargeString, UUID, virtual, to-many paths, String(n) with n > 500.
Tune with @cds.search. The case rule follows the backend the data comes from (cds.requires.<service>.kind):
odata-v2 case-sensitive, odata / odata-v4 and local case-insensitive (tolower), soap matched here only
(every row is read, case-insensitive, nothing is pushed). A backend that rejects tolower (S/4:
Filter function 'TOLOWER' not supported) is remembered and searched case-sensitively: the first request is asked
again without it, and one line is logged per service and process, [remote] - <Service> does not support the filter
function tolower: $search on it is case-sensitive from now on (CAP's own warning with the backend's error is not
logged for it).
A term with & must be quoted: CAP's OData parser ends an unquoted $search at &, also when it is sent as %26
($search=A%26B searches for A). Send $search="A%26B" (%22A%26B%22).
A sound contains(field, word) filter is pushed where possible (including key lists of matching associated rows);
the local match always has the final say. Without any push the read is bounded at 5000 rows; more is a 502 asking for
a $filter that names why nothing was pushed, for example $search could not be pushed to the backend (the term can not
be restricted: calculated column 'Label') and '<Entity>' has more than 5000 rows.
When the pushed filter is the search itself, the page goes to the backend as well: every searched column is a plain
column, the term has no NOT and no word with *, + or %, and the filter is one query (no list of associated
keys split over several). Then $top, $skip, $count and $orderby are sent with it and only the page is read, so a
value help's type-ahead with a broad term reads ten rows, not every match. A backend that answers fewer rows than asked
(server paging) is asked again for the rest. Otherwise every match is read, then matched, counted, sorted and paged here.
*, + and % are plain characters, as in CAP (no wildcards): cc* matches values that contain cc*. They are
never sent to the backend (SAP Gateway and S/4 OData V4 reject them in contains): the longest piece between them is
(cc), and a word of only these characters is not pushed. The rows of a pushed search are read in pages, so a backend
that answers a fixed page size without $top (S/4 OData V4: 100) does not cut the result or $count. Parentheses group; an unmatched ( or ) standing alone ($search=() is a word
and matches literally, one touching a word is ignored ((Acme = Acme). A lone " and & are rejected by CAP's
OData parser (400) before the adapter is called.
When the entity projects on an entity of another service of this package (a layered model) and every SEARCHED column of the entity
is a plain column, the search is handed to that service instead of being turned into a filter over its columns: the lower service
knows which of its columns are path or calculated columns and plans the push-down (and the key pre-queries) itself, so a search
through several layers reads what a search on the lowest layer reads. Columns that are not searched do not matter: an exists
flag or another calculated column of the upper entity is computed for the rows the lower service returns. The upper layer does not
bound its read (the lower layer pushes the search or bounds its own read) and matches the returned rows again. When both search the
same columns (@cds.search) with the same case rule, the lower answer is exact and the page, count and sort are sent down with
the search (see above).
Only a searched path or calculated column of the entity's own (a column the lower entity does not have, for example a path over an association of the lower entity) stops the hand-off: its push-down is planned in its own layer, over the lower entity's associations, and sent to the lower service as a plain filter. A term that matches more than 2000 associated rows cannot be turned into a key list; the search then falls back to the bounded local match (see above).
For a value help over layered entities: keep the searched columns plain (@cds.search: { ID, Name, ... } on columns the lowest
entity has, leave out coded and calculated columns). Flags and other calculated columns may stay in the entity; they are not
searched and do not slow the search down.
DISTINCT and GROUP BY
Only when the entity itself declares distinct / group by. Computed in memory over every source row (read in pages):
count, count_distinct, sum, avg, min, max. When every aggregate is a row count and the $filter fixes
every group column (= / in, at most 25 combinations), one $count request per combination replaces the read.
entity ItemCounts as projection on RemoteOrders.Items { key Category, Status, count(ID) as N : Integer } group by Category, Status;
annotate RemoteOrders.Items with @cds.query.limit: { default: 1000, max: 5000 }; // page size for reading every rowHAVING. A having of the projection filters the groups, for every read of the entity:
entity BusyStages as projection on RemoteOrders.Items { key Stage, count(*) as N : Integer }
group by Stage having count(*) > 1 and Stage in ('OPEN', 'DONE');Terms with an aggregate (count, sum, avg, min, max, also one that is not in the select list) are evaluated on every
group after the aggregation. Terms on group columns only (Stage in (...)) are a where in effect: they go to the backend like the
projection's own where, so fewer rows are read (evaluated here when the backend cannot filter that field). An aggregate of no
value is null, and a comparison with null is false. between is supported in aggregate terms. A having the adapter cannot
apply (no group by, a field that is neither a group column nor inside an aggregate, like, a function it cannot evaluate here,
exists, a sub-select) is answered with 501 instead of unfiltered groups. A $filter on an aggregate column of the request
narrows the groups that remain; $count is the number of groups after the having.
$expand and instance restrictions
CAP evaluates @restrict ... where only on the entity a request targets, not on expanded entities (CAP Node.js "Limitations"). An
association that the backend can expand itself (a managed association of an imported OData service) is normally expanded by the
backend, which can not apply a restriction that depends on the current user:
@(restrict: [{ grant: 'READ', where: (Field = $user.<attr>) }])
entity Targets as projection on Remote.Targets { ... };An expand whose target (or a target further down) has a @restrict privilege for READ / * with a where is therefore not
sent to the backend as $expand: its rows are read through the target's own service, where CAP applies the restriction. Other
expands are unchanged; role-only restrictions are not affected.
🔗 Filters through associations
Filters may go several associations deep, through to-one and to-many associations:
GET /odata/v4/order/Orders?$filter=_Customer/_Group/Region eq 'EMEA'
GET /odata/v4/order/Orders?$filter=_Customer/_Contacts/any(c: c/_Mails/any(m: m/Address eq '[email protected]'))The main entity's backend cannot evaluate these. Instead of reading every order and expanding the associations, the service answers them with semi-joins, hop by hop:
- The first association's target (
Customers) is read with the rest of the condition (_Group/Region eq 'EMEA'), selecting only its join keys. Its own service answers that rest the same way, so a chain of any length resolves level by level. - The main entity is filtered by the keys found:
- at most 200 key values: pushed as
CustomerID in (...)(backend$topand$countstay correct); - more (up to 5000): the same filter in memory; the main entity is read with one request per 200 keys (sent up
to
pagesInParallelat a time) when that takes fewer requests than reading all of its rows (one count request decides), else in full without any expand; - no key: an empty answer, the main entity is not read.
- at most 200 key values: pushed as
- The target is read in pages ordered by its key, up to
pagesInParallelat a time, for at most 5000 rows (cds.query.remote.semiJoin.maxRowsraises it). A page has 1000 rows (cds.query.remote.semiJoin.pageSize: the read selects only the join keys, so a backend that allows larger pages needs fewer requests). A target served by this application gets at most its own page limit (@cds.query.limit.max, elsecds.query.limit.max, else 1000): CAP answers no more per read, and larger pages would be read one after the other). Above that, or when the target's$countdoes not prove the key list complete, the filter is evaluated in memory on hidden expands of the associations (correct, slower). - When the target has more than one page of rows and the rest of the filter leaves at most 5000 rows of the main entity, the filter is evaluated on those rows if that takes fewer requests (their pages plus one association read per 200 rows) than reading the target's keys. One count request decides; the key set cache (Caching) skips it.
A filter Flag = true on an exists flag (case when exists _Items[Status = 'Open'] then true else false end, or
exists _Items as Flag) is the semi-join exists _Items[Status = 'Open'], also when the association is one of the
source only and when the filter comes in parentheses from a layer above. @remote.pushdown: false on the flag keeps it
in memory.
Rules per hop:
- The hop is joined on key pairs: the ON condition, managed foreign keys, or (EDMX associations without ON) the key
names it shares with its target. Composite keys are pushed as
(k1 = a and k2 = b) or ...; empty-string values are left out of the pushed filter (OData V2 backends answerX eq ''with no row) and the exact filter is applied in memory. - A constant on the target side of the ON condition (
and _Partner.Role = 'BP') filters the target read; a constant on the parent side keeps the term in memory. - Supported terms:
=,<,<=,>,>=,in,contains,startswith,endswithon the leaf, andexists/any()with infix filters (nested). Notne(!=): CAP's!=is two-valued, so a row without a target (or with a null value) matches it, and no key list selects such rows; it is evaluated in memory. To ask for a filled value, usegt ''instead ofne ''(a semi-join). A hop whose target is an external service (not a projection served by this application) only accepts a filter on a field of its own. - The target's key reads use the request's user, tenant and locale, so the target's
@restrictapplies.
A filter or sort through an association that cannot be a semi-join (for example or across associations) is evaluated
in memory: the associations it reads are expanded with just the needed fields and removed from the answer again.
🔑 Reads by key
| Request | Backend call |
| --- | --- |
| Orders('O1') | Orders('O1') (key in the source's names); no $top, no count |
| $filter=ID eq 'O1' (every source key pinned with =, or in with one value) | read by key, answer shaped back (list with $count, or SELECT.one) |
| $filter=ID in ('O1','O2','O3') | one read by key per value (at most 50), merged, sorted, counted and paged here |
| $filter=(Company eq 'C1' and No eq '1') or (Company eq 'C2' and No eq '7') | one read by key per key tuple |
| a key tuple that contradicts another pinned key (Owner eq 'A' and ((Owner eq 'B' and ...) or ...)) | dropped: no row, no call |
| an unknown key | 404 Entity '<name>' not found for a key read, an empty list for a filter; a backend that answers an unknown key with 400 instead of 404 is asked again with a filter, so the answer is the same |
A parenthesized group of ANDs counts like its terms (CAP adds a @restrict ... where as (Field = 'value')), so a key
completed by such a term is still a read by key. $count=true on a single entity answers @odata.count: 1.
Backends answer keys in a $filter slowly (a scan) or wrongly; the key in the URL addresses the row. The rest of the
WHERE is not sent (OData V2 services ignore a $filter on such a URL, some OData V4 services reject it) but evaluated on the returned
row (also the projection's own where); a row that fails is 404 (key read) or left out.
When the entity's key is not the source's key (a DISTINCT view, a different key element), Entity('x') is read with a
filter instead.
Navigation by key (Parent('k')/_Children)
GET Orders('O1')/_Items (a to-many or to-one association of a local entity, any number of hops) reads the parent by
key, fills the association's on condition with its values and reads the target with that as a filter: one plain read
of the target (Items where OrderID = 'O1'), with the request's $filter, $orderby, $top, $skip, $count. The
on condition must be = terms joined by and (<assoc>.X = $self.Y, constants allowed); managed associations use
their foreign keys; an association with neither (a navigation property imported from an OData service, redirected to a local entity) is joined on
the key elements both entities share, as $expand does. An unknown parent key is 404 Entity '<parent>' not found; a parent without a value for the join
is an empty list. Every segment may carry its own key or filter (Parent('k')/_Items(ItemID='i')/_Details(DetailID='d'); on a
single-valued navigation it is a filter, as in CAP's path expressions): it narrows the rows of that hop together with the join.
Not supported (501): a path without a key on the first segment, a parameterized segment, another on shape, more than 200 rows
before the last hop.
🧠 Caching
Three opt-in caches, kept in the memory of each process (nothing is shared between instances; an entry lives at most its time to live). All are off until you switch them on; nothing changes for an entity you do not opt in.
Key sets of semi-joins. cds.query.remote.cache.semiJoins: true (or @remote.cache on the association's target or its
service) keeps the key set a filter through an association reads (Filters through associations), per target, filter and
tenant (and user with perUser). Every filter that needs the same key set then answers from memory, whatever else it
filters on. With it on, the key set is always read (to be reused) instead of the in-memory evaluation of a narrow request:
the first request after the time to live pays the full read, unless staleWhileRevalidate is set (below). Writes
through the adapter to the target drop it.
Aggregates. cds.query.remote.cache.aggregates: true caches GROUP BY / DISTINCT entities (a single entity can opt out
with @remote.cache: false, or opt in with @remote.cache: true). The whole group set of a filter is read once;
$top, $skip, $orderby and $count are applied to a copy, so every page and order of that filter shares one entry.
The key is the entity and the effective filter (it includes what CAP's @restrict ... where added), so users with different
restrictions never share an entry; the columns are not part of it (the rows are cached complete and cut to the $select). Not for $search, $expand, SELECT.one, a read by key, @response.data or SOAP.
Rows by key. @remote.cache: true on an entity, or on its service for all its entities:
@remote.cache: true // service level: every entity of the service
service ReferenceData { ... }
@remote.cache: { ttl: 60 } // entity level, own time to live
entity Regions as projection on ...;
@remote.cache: false // opt out of the service's setting
entity LiveSet as projection on ...;A read of a remote entity by key (Entity('K'), $filter=Key eq 'K', key in ('a', 'b'), the key reads that resolve
$expand and path columns) is answered from the cache while the row is fresh. For key in (a, b) each key is one read, so
only the keys that are not cached go to the backend. The entry is the entity, the key and the columns read: another
$select is another entry. Collection reads ($filter on other fields, paging, $search) are not cached. Only the hop
that talks to the backend caches; a projection over another local service does not.
@remote.cache goes next to @remote on a service, it does not replace it: @remote: { cache: true } compiles to
@remote.cache: true only, and the service is then not served by RemoteApplicationService (501 "cannot be served
generically"; the plugin logs a warning). Write @remote and @remote.cache: true as two annotations.
Time to live (seconds, default 300): @remote.cache.ttl on the entity, else on the service, else
cds.query.remote.cache.ttl, else 300.
Stale while revalidate (cds.query.remote.cache.staleWhileRevalidate: <seconds>, default off): an entry past its
time to live is still answered for that many seconds, and one read in the background refreshes it. A request then never
waits for an expensive read (a large key set, an aggregate over every row) once the entry exists; only the very first
request, or one after the stale time too, reads the backend. A write through the adapter still drops the entry at once.
A refresh that fails is logged on cds.log('remote') and the old entry is answered until its stale time ends.
Always: every answer is a copy; an error or an empty answer is not kept; identical reads that arrive while one is running wait for it (one backend read); a write, an action or a function through the adapter drops what is cached from the entity (an unbound operation clears the cache); a read that started before a write does not store its answer.
X-Cache response header: HIT; age=<seconds>, MISS, or PARTIAL; hits=<n>; misses=<m>; age=<seconds> when one HTTP
response needed several lookups (the age is the oldest hit). Without a cached entity involved there is no header.
Limits: per process (with several instances each has its own entries and an entry can be stale for up to its time to
live, also after a write that went through another instance); the data is as current as the last read. Set
cds.query.remote.cache.perUser: true when the backend answers differently per user.
Warm-up (@remote.warmUp)
A cache helps from the second request on. @remote.warmUp makes the first one fast too: the entity is read when the
server starts, and again just before the caches that read filled expire, so users find them filled (a semi-join's key
set, an aggregate over every row).
// read once at start, then again before the filled caches expire
@remote.warmUp
entity ActiveCustomers as projection on ...;
// one read per filter the UI sends: each fills its own cache entry
@remote.warmUp: [
{ where: (HasOpenOrders = true) },
{ where: 'Country = ''DE''', every: 1800, limit: 10 }
]
entity Customers as projection on ...;| Property | Meaning | Default |
| --- | --- | --- |
| where | Filter of the read: a CDS expression (...) (checked by the compiler) or a string in CDS expression syntax (parsed when the server starts; a string that does not parse is logged as an error and that read left out). | none |
| every | Seconds between the end of one read and the next. | 90 % of the shortest time to live of the caches the read went through, less the time the read took. A read that went through no cache runs once (logged). |
| limit | Rows read. A key set or an aggregate is cached whole, whatever the page. | 1 |
| enabled | false switches the record (or @remote.warmUp.enabled: false the entity) off, e.g. in a profile. | true |
How it reads:
- Through the entity's own service, as the privileged user, with every cache lookup of the read a miss: the caches are
read and stored again, also while their entries are still fresh. Once per tenant of
cds.query.remote.warmUp.tenants. - A cache entry is found only for the same effective query. The privileged user gets no
@restrict ... wherefilter, so annotate the layer whose users get no user-dependent filter (the data layer, not the app service above it). - CDS copies the annotation to every projection above the entity. Only the lowest entity of a projection chain with an
enabled warm-up is read; a projection above it would fill the same caches. An expression that names an element a
projection above does not have stops the compiler ("Assign a value for @remote.warmUp.where"): write the
whereas a string then. - A failed read is logged as a warning and tried again after
every, else after 300 seconds. Nothing is warmed withcds.query.remote.cache.perUser(no user would find the privileged user's entries). An annotation on an entity that noRemoteApplicationServiceserves is ignored with a warning.
When it runs (cds.query.remote.warmUp):
enabled: default on in Cloud Foundry (VCAP_APPLICATION) or with theproductionprofile, off in tests and local runs.- Startup: the warm-up starts when the services are served. By default the server does not wait for it; a request
that arrives during a warm-up read waits for that read instead of starting its own. With
await: true(45 seconds) orawait: <seconds>the server listens only after the first reads are done or that time is over (the reads go on in the background). Keep it below the platform's start-up health check timeout (Cloud Foundry: 60 seconds by default). concurrency: reads at the same time at start (default 1). Every instance warms its own caches (they are per process).
✏️ Writing data
CREATE / UPDATE / DELETE on a simple projection are written to its source entity:
entity Orders as projection on RemoteOrders.Orders { key Company, key No, Note as Text, Header.Title as Title } where Kind = 'A';POST OrderssendsINSERTintoRemoteOrders.OrderswithNote,{ Header: { Title } }andKind = 'A'(from thewhere).PATCH Orders(Company='C1',No='1')reads the row by key first (404 when it is outside thewhere), then sendsPATCH Orders(Company='C1',No='1'). After an answer without a row (OData V2: 204) the entity is read again.DELETEreads the row the same way, then deletes it.- Keys: the source's keys come from the URL key and
req.data(local names). When the entity's key is not the source's key, the missing source keys are read by the entity's own key (a handler that knows them can put them intoreq.dataand save that read). - Calculated, virtual and path elements are dropped from the data; an unknown element is 400; data for an association
without
@remote.write.deepis 501. - Database sources are written by CAP's generic handler. JOIN views, static data and SOAP sources are 501.
- Backend errors keep their status (502 without one), code and message.
@remote.write.truncateon the entity or an element cuts strings to the element's length.
Handlers of your service run first: a before handler validates or sets values; an on handler registered before
super.init() replaces the generic write unless it calls next().
Backend-only values
Values the backend needs but clients never send are projected as @readonly (often @UI.Hidden) elements and set by
your handler. CAP drops client values of @readonly elements, not the values your handler puts into req.data.
Non-CRUD backends
For APIs where every change is a POST that names the operation in a field:
@remote.write.asInsert: ['UPDATE', 'DELETE']
@remote.write.operation: { field: 'Op', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }
entity Entries as projection on Remote.Entries { key Company, key Rec, Note, @readonly @UI.Hidden Released };PATCH Entries(...) becomes POST Entries with { Note, Company, Rec, Op: 'U' }. A DELETE sent this way returns
the backend's row when it answers one. Invalid settings (another event in asInsert, an operation field that is not
an element of the source, codes without a field) are a 500 naming the entity.
Write rules
CAP checks @assert after the write, against the database, and rolls back on failure. A remote backend cannot roll
back, so declare the rules with @remote.*; they are evaluated in memory before anything is written:
entity Bookings as projection on Remote.Bookings {
key ID,
Customer,
@remote.assert.target
_Customer : Association to one Customers on _Customer.ID = $self.Customer, // ASSERT_TARGET on Customer
Project,
@remote.assert: (case when not exists _Project or _Project.Customer != Customer then 'ASSERT_TARGET' end)
_Project : Association to one Projects on _Project.ID = $self.Project,
@remote.assert: (case when Day < _Project.StartDate or Day > _Project.EndDate then 'BOOKING_OUTSIDE_PROJECT' end)
@remote.assert.args: [Project, (left(_Project.StartDate, 10)), (left(_Project.EndDate, 10))]
Day,
@remote.write.truncate
@remote.write.value: (Customer || ' - ' || _Customer.Name)
CustomerText
};@remote.assert: a CASE expression; the first matchingwhengives the message (text or i18n key, looked up like CAP's@assert), target = the element.@remote.assert.argsgives{0},{1}, ... (paths or(<expression>)).@remote.assert.targeton a to-one association: the target must exist (ASSERT_TARGET, target = its foreign key).@remote.write.value: computed when the write's data has no value (a value from the caller or a handler wins); on UPDATE computed from the stored row merged with the data.- Expressions read the entity's elements and one-hop to-one paths (
_Assoc.Field,exists _Assoc,not exists _Assoc), with CASE,||,+ - * /, comparisons,and/or/not,is [not] nulland the in-memory functions. Each association is read once per write through the service that owns its target. Deeper or to-many paths are 501. - All failing checks are collected: one 400 error, or
MULTIPLE_ERRORSwithdetails. - A date compared with a date-time compares as instants.
Deep writes
entity Projects as projection on Remote.Projects {
key ProjectID, Name,
@remote.write.deep
_Packages : Association to many Packages on _Packages.ProjectID = $self.ProjectID
};
entity Packages as projection on Remote.Packages { key ProjectID, @mandatory key PackageID, Description };POST/PATCH Projectswith_Packages: [...]writes the project, then every package row as an upsert (PATCHwhen a row with its key exists, elsePOST; a row without all its keys is created), the join keys filled in from the parent, recursively for the packages' own@remote.write.deepassociations.- Checked before anything is written: the shape (to-many: an array of objects, to-one: an object; 400) and
@mandatoryof every child row (join keys from the parent excepted). - A failing row does not stop its siblings (its own children are skipped). All failures come back as one
MULTIPLE_ERRORS(400 when all are 4xx, else 502) with the path of each row (_Packages[1]/_Items[0]). Rows written before stay written: there is no transaction across backend requests. Children left out of the payload are not deleted. No ETag handling.
⚙️ Actions and functions
Operations of the local service are forwarded to the source without a handler:
entity Documents as projection on Remote.Documents { key DocNo as No, Title } actions {
action Cancel() returns many Remote.CancelResult; // bound operation of the same name on Remote.Documents
function GetPDF() returns Remote.PDF;
@remote.operation: 'Release'
action ReleaseDocument(); // bound operation `Release` of Remote.Documents
};
@remote.operation: 'Remote.upsertStatus' // unbound operation of another service
action setStatus(DocNo : String(10), Status : String(20)) returns Statuses;- Bound: the URL key is translated to the source's names and sent as the binding parameter (OData V2: a function import with the keys as parameters), the parameters as data.
- Unbound: the parameters are sent as they are (their names must be the source operation's names).
returns X/returns many XwithXan entity of this service over a remote source: rows mapped to local names (calculated columns included). Other answers are returned as they come.- A handler you register before
super.init()wins.@remote.operationnaming no existing operation failsinit(). - Backend errors keep their status (502 without one) and message.
🔌 Class API
import { RemoteApplicationService } from '@cap-ts/remote-service-adapter';
export class OrderService extends RemoteApplicationService {
async init(): Promise<void> {
this.after('CREATE', 'Entries', async (row, req) => {
// a further write inside the same, already authorized request
await this.writeSource('UPDATE', 'Entries', { ...row, Released: true }, { checkWhere: false });
});
await super.init();
}
}| Member | Description |
| --- | --- |
| init() | Registers READ, CREATE / UPDATE / DELETE and the operation forwarding for every entity, then super.init(). Called by CAP. |
| readSource(entity, where, { columns }?) | One row through the read pipeline without the service's handlers (aliases, calculated columns, the projection's where; a filter on the source key is a read by key). where = element values compared with =. Returns the row in local names or undefined. |
| writeSource(event, entity, data, { checkWhere, checks }?) | The generic write without the service's handlers and without CAP's authorization and input checks; @readonly values are kept. checkWhere: false skips the read of the row against the projection's where; checks: false skips the write rules. Only from a handler that has authorized the request. |
| supportsSkipPagination | true: other instances may send this service the internal "all rows" hint of association fetches. |
entity is the definition or the name (with or without the service prefix); user, tenant, locale and headers come
from cds.context. Both methods are 501 for an entity without a remote source.
Every request gets an 8-character correlation id that appears in every log line of that request.
🧩 CAP plugin: the @remote annotation
@remote
service Catalog {
entity Partners as projection on ExternalPartners.Partners { key ID, Name as Title };
}@remoteon the local service is the entire opt-in. The plugin does not look at what the entities project on or atcds.requires;cds.requires.<name>only configures how the sources are reached.- A service keeps its own implementation when it has
@impl,cds.requires.<service>.impl, or a handler file CAP would load (<name>.js/.mjs,.tswith TypeScript, next to the.cds, inlib/orhandlers/). To combine both, extendRemoteApplicationServicein that file. - A service marked external (
@cds.external,@external,cds.requires.<name>.external) is never patched. - Do not point
cds.requires.<external>.implatRemoteApplicationService: it reads throughcds.connect.to(<external>), which would return itself. DEBUG=remote-servicelogs which services were patched.
🧼 SOAP backends
{
"cds": {
"requires": {
"Partners": {
"kind": "soap",
"wsdl": "srv/external/wsdl/Partners.wsdl",
"credentials": {
"destination": "PARTNERS_DESTINATION"
}
}
}
}
}Annotate the external model as @cap-ts/soap-adapter documents. Reads go through soap.read (a fresh request per
call, forbidden headers stripped), results are de-duplicated by the entity's keys, filter functions run in memory,
$search matches every row here, case-insensitively (nothing is sent to the SOAP service). An association declared on
the SOAP entity (extend <Soap>.Entity with { _X : Association to ... }) works for $expand, path columns and filters
through it. SOAP sources are not writable, and a filter on
the key is not turned into a read by key (the SOAP operation gets its parameters from the URL key or a flat and of
equalities).
🛠️ Configuration and logging
Nothing has to be configured: the service is driven by cds.requires.<Service> and the annotations. Global runtime
knobs live under cds.env.query.remote (the cds.query.remote key in package.json / .cdsrc.json, or an environment
variable such as CDS_QUERY_REMOTE_PAGESINPARALLEL=3), next to CAP's own cds.query.limit. All are optional; the values
shown are the defaults, except search.maxRows, which has none (5000 is an example):
{
"cds": {
"query": {
"remote": {
"pagesInParallel": 5,
"paging": true,
"capClientLog": false,
"search": { "maxRows": 5000 },
"semiJoin": { "maxRows": 5000, "pageSize": 1000 },
"join": { "maxRows": 5000 },
"cache": {