Building a CAP Plugin with Custom Annotations

 

In this post we’ll build a minimal working example that reads a custom @mask annotation from your CDS model and automatically masks those fields on every READ.

We use cds-plugin to include the plugin in the project in a simple way.

Original article may be read here.

Create a CAP Project

cds init annotation-demo
cds add nodejs
cd annotation-demo
npm install

Edit db/schema.cds:

namespace demo;
using { cuid } from ‘@sap/cds/common’;

entity Users : cuid {
  name     : String(100);
  email    : String(200) @mask;
  password : String(200) @mask;
}

Here you can see the custom annotation @mask. We will look at this later.

Next, configure the service atsrv/user-service.cds:

using { demo } from ‘../db/schema’;

service UserService @(path: ‘/users’) {
  entity Users as projection on demo.Users;
}

Edit package.json to use Sqlite as follows:

{
  ...
  “cds”: {
    “requires”: {
      “db”: {
        “kind”: “sqlite”,
        “credentials”: {
          “database”: “db.sqlite”
        }
      }
    }
  }
}

Also add some test data by creating db/data/demo-Users.csv:

ID,name,email,password
aaaaaaaa-0000-0000-0000-000000000001,John,john@example.com,password123
aaaaaaaa-0000-0000-0000-000000000002,Mary,mary@example.com,password456

Write the Plugin

We can now implement @mask . CAP doesn’t need it declared anywhere, it will appear in the CSN node as element['@mask'] = true at runtime.

First, create mask-plugin/cds-plugin.js:

const cds = require(’@sap/cds’);
cds.on(’served’, (services) => {
  for (const srv of Object.values(services)) {
    if (!srv.entities) continue;
    for (const entity of Object.values(srv.entities)) {
      for (const [fieldName, element] of Object.entries(entity.elements || {})) {
        if (element[’@mask’]) {
          srv.after(’READ’, entity, (data) => {
            const records = Array.isArray(data) ? data : data ? [data] : [];
            for (const r of records) {
              if (r?.[fieldName]) {
                r[fieldName] = r[fieldName].replace(/./g, ‘*’);
              }
            }
          });
          console.log(`[mask-plugin] masking: ${entity.name}.${fieldName}`);
        }
      }
    }
  }
});

Here cds.on('served') fires after all services are mounted and their CSN is compiled. This is where the model is inspected and handlers are registered.

The srv.after('READ') intercepts every READ response for that entity before it is sent to the client.

Finally, element['@mask'] reads the annotation value off the CSN node;

Next, create mask-plugin/package.json:

{
  “name”: “mask-plugin”,
  “version”: “1.0.0”,
  “main”: “cds-plugin.js”,
  “cds”: { “plugin”: true },
  “peerDependencies”: {
    “@sap/cds”: “>=7”
  }
}

The "cds": { "plugin": true } flag is what tells CAP to treat this as a plugin. CAP looks for this exact filename in every installed package marked as a plugin.

Next we have to add the plugin to the main project package.json:

{
  ...
  “dependencies”: {
    ...
    “mask-plugin”: “file:./mask-plugin”
  },
  ...
}

That is all that is required to add the plugin.

You can now run the project using cds start . Look out for the following log lines:

[mask-plugin] masking demo.Users.email
[mask-plugin] masking demo.Users.password
[mask-plugin] masking UserService.Users.email
[mask-plugin] masking UserService.Users.password

This indicates that the plugin did indeed register itself. If you start with cds watch, for example, you will find these log lines not printed, meaning that the plugin did not register.

Testing

Now you can test as follows:

curl -s http://localhost:4004/users/Users

You should get a response similar to the following:

{ “name”: “John”, “email”: “*****************”, “password”: “**************” }
{ “name”: “Mary”,   “email”: “***************”,   “password”: “***********” }

Why This Pattern Is Useful

The cds-plugin approach is what SAP uses for its own plugins like @cap-js/sqlite@cap-js/audit-log, and @cap-js/attachments.

  • No configuration for package consumers

  • Model-driven behaviour

Using this you can extend the same idea to soft-deletes, audit logging, rate limiting, computed fields, or anything that would otherwise have to be duplicated and copy-pasted across different services.

Comments

Popular Posts