← All posts

Your First SAP CAP Service: From Zero to Fiori in 30 Minutes

Build the equipment request app from the RAP post again — this time with the SAP Cloud Application Programming Model (CAP), Node.js and OData V4, then deploy it to BTP.

CAPBTPCDSNode.jsOData V4

In the RAP post we built an Equipment Request app on the ABAP stack. Here we build the same app with CAP — SAP's Cloud Application Programming Model — using Node.js. Same domain, different runtime: a good way to see where each model shines.

RAP or CAP?

RAP CAP
Runs on ABAP (S/4HANA, BTP ABAP environment) Node.js or Java (BTP Cloud Foundry / Kyma)
Data model CDS view entities + DDIC tables CDS entities (database generated)
Logic ABAP behavior pool, EML JavaScript/Java event handlers
Typical use Extensions close to S/4 data Side-by-side apps, new products, multi-system scenarios

Neither is "better": RAP is the natural choice when the logic belongs inside S/4HANA; CAP fits side-by-side extensions on BTP.

1. Set up the project

You need Node.js (LTS) and the CAP tools. VS Code or SAP Business Application Studio both work.

terminal.sh
# once per machine
npm install -g @sap/cds-dk

# new project
cds init equipment-requests
cd equipment-requests
npm install

# start with live reload (in-memory SQLite + sample data)
cds watch

2. Domain model

db/schema.cds describes the data. The cuid and managed aspects add a UUID key and the created/changed fields — the CAP counterpart of the administrative fields we wrote by hand in RAP.

schema.cds
namespace sap.equipment;

using { cuid, managed, Currency } from '@sap/cds/common';

entity Requests : cuid, managed {
  requestNo     : Integer @readonly;
  description   : String(80) @mandatory;
  equipmentType : String(20) @mandatory;
  estimatedCost : Decimal(15, 2);
  currency      : Currency;
  status        : String(1) default 'O' @readonly enum {
    Open     = 'O';
    Approved = 'A';
    Rejected = 'X';
  };
}

Sample data goes into db/data/sap.equipment-Requests.csv; CAP loads it automatically:

sap.equipment-Requests.csv
ID;requestNo;description;equipmentType;estimatedCost;currency_code;status
6f1c2a90-1b3e-4c55-9d7a-0e7f5b1a2c01;1;Laptop for new developer;Laptop;1450.00;EUR;A
6f1c2a90-1b3e-4c55-9d7a-0e7f5b1a2c02;2;Ergonomic keyboard;Accessory;89.00;EUR;X
6f1c2a90-1b3e-4c55-9d7a-0e7f5b1a2c03;3;Docking station for meeting room;Docking;320.00;EUR;O

3. Service definition

srv/service.cds exposes a projection as an OData V4 service. @odata.draft.enabled gives us the same draft handling as with draft in RAP, and the two bound actions are declared right on the entity.

service.cds
using { sap.equipment as db } from '../db/schema';

service EquipmentService @(path: '/equipment') {
  @odata.draft.enabled
  entity Requests as projection on db.Requests actions {
    action approve() returns Requests;
    action reject()  returns Requests;
  };
}

4. Custom logic

srv/service.js registers event handlers — the CAP equivalent of determinations, validations and action implementations:

service.js
const cds = require('@sap/cds');

module.exports = class EquipmentService extends cds.ApplicationService {
  init() {
    const { Requests } = this.entities;

    // Next request number when a draft is activated (demo: MAX + 1)
    this.before('CREATE', Requests, async (req) => {
      const { max } = await SELECT.one`max(requestNo) as max`.from(Requests);
      req.data.requestNo = (max ?? 0) + 1;
    });

    // Validation – the message is attached to the field in Fiori
    this.before(['CREATE', 'UPDATE'], Requests, (req) => {
      const { estimatedCost } = req.data;
      if (estimatedCost !== undefined && estimatedCost <= 0) {
        req.error(400, 'Estimated cost must be greater than zero', 'estimatedCost');
      }
    });

    this.on('approve', Requests, (req) => this.setStatus(req, 'A'));
    this.on('reject', Requests, (req) => this.setStatus(req, 'X'));

    return super.init();
  }

  async setStatus(req, status) {
    const { Requests } = this.entities;
    const [{ ID }] = req.params;
    const request = await SELECT.one.from(Requests, ID).columns('status');
    if (!request) return req.reject(404, 'Request not found');
    if (request.status !== 'O') return req.reject(409, 'Only open requests can be approved or rejected');

    await UPDATE(Requests, ID).with({ status });
    return SELECT.one.from(Requests, ID);
  }
};
  • before('CREATE') → like a determination on save: assigns the next request number.
  • before(['CREATE','UPDATE']) → validation; req.error attaches the message to the field.
  • on('approve') / on('reject') → action implementations with a status check.

5. Run it

Run cds watch in the terminal. CAP compiles the model, creates an in-memory SQLite database, loads the CSV and serves the service — and reloads on every save.

cds watch: model loaded, data deployed, server listening on port 4004
cds watch: model loaded, data deployed, server listening on port 4004

Open http://localhost:4004. The welcome page lists the service endpoints and a Fiori preview link.

The CAP welcome page and the OData response
The CAP welcome page and the OData response

To test without a UI, an .http file works great with the REST Client extension:

requests.http
### List requests
GET http://localhost:4004/odata/v4/equipment/Requests?$filter=IsActiveEntity eq true

### Create a draft
POST http://localhost:4004/odata/v4/equipment/Requests
Content-Type: application/json

{ "description": "Second monitor", "equipmentType": "Monitor", "estimatedCost": 289.90, "currency_code": "EUR" }

### Activate the draft (replace <ID>)
POST http://localhost:4004/odata/v4/equipment/Requests(ID=<ID>,IsActiveEntity=false)/EquipmentService.draftActivate
Content-Type: application/json

{}

### Approve (replace <ID>)
POST http://localhost:4004/odata/v4/equipment/Requests(ID=<ID>,IsActiveEntity=true)/EquipmentService.approve
Content-Type: application/json

{}

6. Fiori Elements annotations

Like the metadata extension in RAP, UI annotations live in a separate file, app/annotations.cds:

annotations.cds
using EquipmentService as service from '../srv/service';

annotate service.Requests with @(
  UI.HeaderInfo      : {
    TypeName       : 'Equipment Request',
    TypeNamePlural : 'Equipment Requests',
    Title          : { Value: description },
    Description    : { Value: requestNo }
  },
  UI.SelectionFields : [ requestNo, equipmentType, status ],
  UI.LineItem        : [
    { Value: requestNo,     Label: 'Request No.' },
    { Value: description,   Label: 'Description' },
    { Value: equipmentType, Label: 'Equipment Type' },
    { Value: estimatedCost, Label: 'Estimated Cost' },
    { Value: status,        Label: 'Status' },
    { $Type: 'UI.DataFieldForAction', Action: 'EquipmentService.approve', Label: 'Approve' },
    { $Type: 'UI.DataFieldForAction', Action: 'EquipmentService.reject',  Label: 'Reject' }
  ],
  UI.Facets          : [{
    $Type  : 'UI.ReferenceFacet',
    Label  : 'General Information',
    Target : '@UI.FieldGroup#General'
  }],
  UI.FieldGroup #General : { Data: [
    { Value: description },
    { Value: equipmentType },
    { Value: estimatedCost },
    { Value: currency_code },
    { Value: status }
  ]}
);

Refresh the Fiori preview: list report, object page, draft and the Approve/Reject buttons are all there.

7. Deploy to BTP

For production, CAP adds SAP HANA Cloud, XSUAA authentication, an approuter and an MTA descriptor with one command:

deploy.sh
# production building blocks: SAP HANA Cloud, XSUAA, approuter, MTA descriptor
cds add hana,xsuaa,approuter,mta

# build the multi-target archive and deploy it to Cloud Foundry
npm install
mbt build
cf login -a https://api.cf.eu10.hana.ondemand.com
cf deploy mta_archives/equipment-requests_1.0.0.mtar

After the deployment, assign the role collections in the BTP cockpit and add the app to the launchpad (SAP Build Work Zone).

What to take away

  • CAP gets you from an empty folder to a working OData V4 service with draft in minutes.
  • The concepts map one-to-one to RAP: entities ↔ view entities, handlers ↔ behavior implementation, annotations ↔ metadata extensions.
  • Choose by where the logic belongs: inside S/4HANA → RAP, side-by-side on BTP → CAP.

Comments

Leave a comment