Gathering detailed insights and metrics for @sap/cds-oyster
Gathering detailed insights and metrics for @sap/cds-oyster
Gathering detailed insights and metrics for @sap/cds-oyster
Gathering detailed insights and metrics for @sap/cds-oyster
npm install @sap/cds-oyster
Typescript
Module System
Node Version
NPM Version
Cumulative downloads
Total Downloads
Last Day
0%
NaN
Compared to previous day
Last Week
0%
NaN
Compared to previous week
Last Month
0%
NaN
Compared to previous month
Last Year
0%
NaN
Compared to previous year
4
2
This is an alpha-preview of a secure execution environment for tenant-specific code in the Node.js version of CAP.
This functionality is experimental and not meant for productive use! The sandbox environment is delivered for interested stakeholders to evaluate the scope, security, and performance to provide feedback and additional requirements for continued development.
In the alpha state, the event umbrella and hence the API to code against can be changed frequently, invalidating already deployed handlers with a new npm install
. Please make sure to check this section, when extensions crash or expose unexpected behavior after each new install, since we to our best to list all incompatible changes here.
These constructs are now deprecated , and should be replaced by the respective alternatives as soon as possible:
f(){...}()
is deprecated and replaced by module.export
this.*
, so please search and replace for all occurrences of srv.*
after(result, req)
. See the documented after handlers.The runtime component of Oyster is needed for deployment of the application. The Oyster SDK is only needed for local development and specific use cases, where developers want to provide a custom shell for the Code Sandbox (framework modifications). This dependency is managed for you by the plugin and should not be modified manually.
Add the @sap/cds-oyster dependency to your project package.json
of both, the Base Application and the MTX sidecar
1"@sap/cds-oyster": "latest"
The minimal configuration to enable the sandbox is
1"cds": { 2 "requires": {... 3 "code-extensibility": true 4 } 5}
The same switch can also be utilized within the extension project itself to test extensions locally before activation.
Upon enabling the sandboxed extension feature, the runtime is extended with the additional capability to execute tenant-specific custom code securely. The custom code is written as plain JavaScript files, which are deployed as part of a standard CAP extension project.
The developer experience is very similar to writing regular CAP event handlers, the only difference is that each event handler requires an individual file and has to follow a strict naming convention. The deployment and execution of custom logic require the new MTX-S component and applies to multi-tenant applications.
To ensure secure handling, the sandbox environment is decoupled from the runtime through a limited API, and the code is scanned before deployment/activation for potentially harmful or resource-consuming constructs. Limits to memory consumption, execution time, query result size and coding constructs apply. In addition, code scanning is always applied upon activation and checks for the following constructs
What | Description | Mitigation |
---|---|---|
Globals | The globals Object, Reflect, Symbol, Proxy, global, globalThis cannot be accessed within the sandbox. Any usage will be rejected at deployment time | Extension Developers need to simply live with this limitation |
Require | Is it not possible to require any library beyond the limited API provided to the sandbox | |
Console | The console object is available locally in development mode (CDS watch), but extensions cannot be deployed to MTX using it | Remove all console statements before activating custom code |
Object properties | access to prototype or __proto__ is also completely prohibited | |
Asynchronous calls | await is prohibited generally - except for the data access API (QL) as well as this.send and this.emit where it is actually required | We have yet to see a valid use case for asynchronous calls within the sandbox when I/O is generally disallowed. Helper functions and system libraries can be called synchronously |
Throw Statement | No errors can be thrown within the sandbox | Either call req.reject or call req.error |
Generator Functions | Generator functions and yield are error prone, a frequent cause of memory leaks and should serve no useful purpose in the sandbox | |
Debugger Statement | In local single-tenancy mode, debugging the sandbox is allowed and supported through the debug mode. When deploying to a multi-tenant application, debugger statements are prohibited | Remove all debugger statements before activating custom code |
After adding the CDS Plugin, the following switch in the application configuration in package.json
will enable the code sandbox in the project:
1"cds": { 2 "requires": { 3 "code-extensibility": { 4 "runtime": "oyster", 5 "maxTime": 1000, 6 "maxMemory": 4 7 }, 8 ...
The possiblle parameters are all optional and follow the following specification
Parameter | Explanation |
---|---|
runtime | oyster is the default runtime and must be used for deployment. debug allows local debugging within local extension projects only |
maxTime | in milliseconds |
maxMemory | in megabytes |
The same switch can also be utilized within the extension project itself to test extensions locally before activation.
In an existing extension project (see here for a jumpstart tutorial), code extensions can be created within the SRV folder following a strict naming convention, with the service name as top level folder and service entity name as second level folder. The filename must follow the format of WHEN-dash-WHAT.js
. If you are following the jumpstart tutorial, a valid file would be srv/ProcessorService/Incidents/after-READ.js
.
Every event handler should follow the same pattern of exporting exactly one callable async function to the outside world. The easiest method is to use module.exports
1async function doSomething(req) {
2 // your code here
3}
4module.exports = doSomething
or
1module.exports = async function doSomething(req) {
2 // your code here
3}
Note that creating a self-invoking function using the pattern
1;(async function () { 2 // your code here 3})()
doesn't work anymore.
For the Incidents entity, a valid after read event handler would look like this:
1module.exports = async function modifyComponent(req) {
2 req.results.forEach(
3 row => row.component = " Custom Handler here"
4 )
5 }
Note the first line of the event handler. The req
object represents a subset of the request in CAP. It should look like, and behave the same as normal CAP application level handlers, but without any callable functions.
Developers can create custom logic for (Planned events in {brackets}
):
When | What | Useful Scope | Example Usage |
---|---|---|---|
Before | Create, Update, Delete {Read, Upsert} | Request Payload req.data | Manipulate req.data with e.g custom calculations. Validate input against constraints and reject requests |
After | Read {Create, Update, Delete, Upsert} | Response req.results | Manipulate req.results in read handlers to display calculated fields, but also asynchronously trigger events after any operation. Note, the DB transaction of the event in question has already finished. QL requests will be executed within a new transaction |
On | Event | Inbound Interface req.data | Custom coding will run within the context of the calling transaction, so this one is useful to enable BADI-like extension points |
On | Bound/Unbound Action and Function | Inbound Interface req.data , Response return | This option can also be used well for BADI-like extension points. Application developers can provide action definitions without an implementation and invoke them as needed |
It is planned to also support draft events in a subsequent version, but for now, all event handlers will be triggered after the draft workflow only.
Sandboxed code is executed within the CAP event loop coexisting with other generic or application-specific event handlers, and it is not guaranteed that it runs at a specific point in time. If application developers want to ensure a specific execution order, they should consider only a BADI-like approach.
The available API within custom handlers is limited to a subset of the req
object and contains
1subject: req.subject, 2data: req.data, 3target: req.target, 4results: req.results, 5errors: req.errors, 6messages: req.messages
and the ability to call the req.error(...)
and req.reject(...)
methods.
The req
object is available to the extension developer as the handler parameter and doesn't need a require statement or initialization. Throwing errors within custom code is not possible. Unhandled exceptions are propagated to the outer shell of the sandbox and handled generically without a meaningful semantic error message.
Instead, developers should either call the req.reject(...)
method or call the req.error(...)
method. Developers are free to manipulate the req.data
and req.results
arrays but should be aware that adding attributes beyond the application model will be ignored by the framework and won't appear in subsequent processing. As per the table above, while the full req
object is always inherently defined, manipulating data within only makes sense to the specific context. A before UPDATE
handler has an undefined results array, so whatever the sandboxed code does to it, will be overwritten by subsequent processing of the CAP framework.
In addition, developers can asynchronously call SELECT
, INSERT
, UPDATE
, DELETE
, and UPSERT
on service level with authorization enforced on request-user level. Calls to database entities will be rejected with an error message. Alternatively one can use direct service calls like this.read
or this.update
. Finally unbound actions of the service can be called like this.someAction(data:{object})
.
A more complex example would be to display the customer's e-mail directly in the Incidents list. We also want to display a useful default for the component if there is no one selected yet. You need to extend the data model first with
1extend Incidents with { 2 virtual customerEmail: String @title: 'Customer Email'; 3}
This virtual element will be filled at runtime in the handler ProcessorService/Incidents/after-READ.js
1module.exports = async function processEmail(req) { 2 for (let r of req.results) { 3 const { email } = await SELECT.one.from('ProcessorService.Customers').where({ ID: r.customer_ID }) 4 r.customerEmail = email ?? 'No email provided' 5 r.component = r.component ?? 'Not yet defined!' 6 } 7}
The core of this function is the for of
loop. We can operate on the req.results
object like in normal application-level event handlers. Since the SELECT
is executed asynchronously, we cannot use a forEach
statement, and need to loop synchronously instead with for of
.
The SELECT
statement operates on the application service level, meaning the after READ
handler for Customers - if any is present - will also be executed.
Keep in mind that adding a query on incidents in the customer read handler would create a recursive loop and would lead to the request timing out. When querying data, be aware that custom event handlers cannot throw or raise their own errors. You can use try / catch
statements to prevent your code from aborting without a meaningful error message.
To make queries more convenient to developers, the API provides a list of entities in this.entities
1const {Customers} = this.entities 2 3module.exports = async function processEmail(results) { 4 for (let r of results) { 5 const { email } = await SELECT.one.from(Customers).where({ ID: r.customer_ID }) 6 r.customerEmail = email ?? 'None Provided' 7 } 8}
Within an extension project, extension developers need to create a new folder srv/ServiceName/EntityName
and create new files according to a naming convention. Event handlers are registered based on Service Entity, Event Type and Event Timing:
1srv/ServiceName/EntityName/when-WHAT.js
Servicename
is the fully qualified service name within the application. Note: This includes namespaces, if they are used, even if the services are exposed with simplified names
EntityName
is optional. When registering a CRUD handler or a bound action, the Entity name must be specified. For unbound actions and application level events, the files should be placed within the service level folder
when
refers to the implementation hook. For CUD events, it should be before
, for read events after
and for actions and events on
WHAT
would specify the name of the event. Framework events are CREATE, UPDATE, DELETE, READ
. Application Level Events and Actions are called as defined in the model.
All parameters are case sensitive and application level events as well as event and action signatures need to be statically defined in the model.
Code extensibility is also reflected in the extension allow list:
1"cds": { 2 "requires": {... 3 "cds.xt.ExtensibilityService": { 4 "namespace-blocklist": "com.sap.", 5 "extension-allowlist": [ 6 { 7 "for": ["ServiceName"], 8 "kind": "entity", 9 "new-fields": 4, 10 "code" : ["CREATE", "READ", "UPDATE", "DELETE", "action", "function"] // this applies to bound actions and functions 11 }, 12 { 13 "for": ["ServiceName.EntityName"], 14 "kind": "entity", 15 "code" : ["READ"] 16 }, 17 { 18 "for": ["ServiceName"], 19 "kind": "service", 20 "new-entities": 1, 21 "code" : ["action", "function"] // this applies to unbound actions and functions 22 } 23 ] 24 } 25 } 26 }
CDS-OYSTER exposes a limited subset of the query API described in Capire. In this guide, only the relevant differences to the full query API will be described. On a general note, a lot of features of the CQL rely on the capability of the CDS compiler to inspect the model and resolve element names. Enabling this in an encapsulated sandbox would require serializing potentially very large models, posing a significant performance impact.
The sandbox API is limited to the SELECT.from
syntax for service entities of the target service only.
1let q = SELECT.from('Incidents').where({ID:201}).orderBy({title:1})
2// is equivalent to
3let r = SELECT.from('ProcessorService.Incidents').where({ID:201}).orderBy({title:1})
There is a convenience function this.entities
which can be used to make the code easier to read:
1const {Customers} = this.entities 2 3module.exports = async function processEmail(results) { 4 for (let r of results) { 5 const { email } = await SELECT.one.from(Customers).where({ ID: r.customer_ID }) 6 r.customerEmail = email ?? 'None provided' 7 } 8}
Clause | Syntax | Notes |
---|---|---|
from | .from(target) | target must be a string and point to a service entity. A fully qualified name and only the entity name are allowed, but the fully qualified name must match the service of this . It must precede the .columns call |
columns | .columns([columns]) | columns expects an array of string or a single string as argument. Columns names are case sensitive. as is supported, but not semantically checked at design time and activation. Functions supported are count(*) , avg(column) as alias , max(column) as alias , min(column) as alias and sum(column) as alias |
one | .one([columns]) or .one | one can replace .columns and adds limit: { rows: {val:1}, offset: {val:0} } to the query. The query result is an object containing a single row as opposed to an array of objects in all other queries. You can also use one without parameters to select the full row. one must precede the .from call |
distinct | .distinct([columns]) or .distinct | distinct also can replace columns and can be called with and without parameters. It too must precede the from clause |
where | .where({valid CQN expression}) | The exercise section contains multiple examples for valid where clauses. |
limit | .limit(num, offset) | TODO: Implement offset |
order by | orderBy([columns]) |
The only syntax supported for inserting is INSERT.into(EntityName).entries({ObjectNotation})
If you have an unbound action defined for incidents like this
1 2extend service AdminService with { 3 action createIncident(customer: Integer, title: String) returns String; 4}
then a valid event handler for it srv/AdminService/on-createIncident.js
would be
1function createGuid () { 2 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 3 const r = (Math.random() * 16) | 0, 4 v = c == 'x' ? r : (r & 0x3) | 0x8 5 return v.toString(16) 6 }) 7} 8const { Incidents } = this.entities 9module.exports = async function createIncident (req) { 10 const res = await INSERT.into(Incidents).entries([ 11 { ID: createGuid(), customer_ID: req.data.customer, title: req.data.title } 12 ]) 13 return 'Success' 14}
In case you find a bug, please report an incident on SAP Support Portal.
This package is provided under the terms of the SAP Developer License Agreement.
No vulnerabilities found.
No security vulnerabilities found.