← Back to tutorials

Your first SAP CAP service in TypeScript: model, validations and computed fields

Build an OData service for the library with SAP CAP: CDS model, initial data, validation with before and a virtual field with after. All in TypeScript and tested with real requests.

· Updated

También disponible en español →

In the RAP series we built the library inside ABAP. Now we do the same outside the system with the SAP Cloud Application Programming Model (CAP): SAP’s programming model for services on BTP with Node.js or Java. If you come from RAP, the mental map is almost the same:

RAP CAP
Table + CDS View Entity Entity in db/schema.cds
Service Definition service in srv/*.cds
Validation in the Behavior Implementation before handler
Virtual field with logic virtual + after READ handler
OData V4 Service Binding CAP does it on its own, no configuration

Requirements

  • Node.js 22 or later. CAP 10 does not run on older versions.
  • The CAP development tools, installed once globally:
npm i -g @sap/cds-dk

The global package ships the cds command and a copy of the CAP runtime, which is enough to develop on your machine. A server has nothing installed globally, so in step 1 we declare the runtime in the project itself.

1. Create the project

cds init biblioteca --add typescript,sqlite
cd biblioteca
npm install
npm install @sap/cds
npm pkg set name=biblioteca version=1.0.0
  • npm install @sap/cds declares the CAP runtime as a project dependency. cds init does not, and without it the service would not start once deployed.
  • npm pkg set gives the project a name and a version, which cds init leaves empty. CAP uses them as the title of its start page; without them, it reads “undefined undefined”.

cds init creates the standard layout:

biblioteca/
├── app/        ← user interfaces (not used here)
├── db/         ← data model and initial data
├── srv/        ← services and their logic
├── package.json
└── tsconfig.json
  • typescript: sets up tsconfig.json, tsx and cds-typer, which generates TypeScript types from your CDS model. This tutorial does not use them yet; if you do, keep in mind that cds watch only generates them when they do not exist: after changing a .cds file, regenerate them with npx cds-typer "*".
  • sqlite: a local database for development, with nothing else to install.

2. The data model

db/schema.cds:

namespace biblioteca;

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

entity Authors : cuid, managed {
  name    : String(100) @mandatory;
  country : String(2);
  books   : Association to many Books on books.author = $self;
}

entity Books : cuid, managed {
  title  : String(200) @mandatory;
  pages  : Integer;
  stock  : Integer default 0;
  author : Association to Authors;
}
  • cuid adds a UUID ID key that CAP generates on create.
  • managed adds createdAt, createdBy, modifiedAt and modifiedBy, which CAP fills in too. It is the equivalent of RAP’s abp_* administrative fields.
  • @mandatory makes CAP reject a book without a title or an author without a name, with no code.
  • The two associations form the relationship: a book points to its author, and an author sees all their books (on books.author = $self).

Association or composition? If books were Composition of many Books, the books would be “part of” the author: deleting an author would delete their books. In a library a book has a life of its own, so we use Association. Keep composition for true parent-child relationships, such as an order and its items.

3. Initial data

CAP automatically loads the CSV files in db/data/ named <namespace>-<Entity>.csv.

db/data/biblioteca-Authors.csv:

ID,name,country
a1b2c3d4-0000-4000-8000-000000000001,Ana Rojas,CL
a1b2c3d4-0000-4000-8000-000000000002,Carlos Soto,AR
a1b2c3d4-0000-4000-8000-000000000003,Elena Vidal,ES

db/data/biblioteca-Books.csv:

ID,title,pages,stock,author_ID
b1b2c3d4-0000-4000-8000-000000000010,Aves del sur,320,4,a1b2c3d4-0000-4000-8000-000000000001
b1b2c3d4-0000-4000-8000-000000000011,Ríos de montaña,180,0,a1b2c3d4-0000-4000-8000-000000000001
b1b2c3d4-0000-4000-8000-000000000012,El último tren,410,7,a1b2c3d4-0000-4000-8000-000000000002
b1b2c3d4-0000-4000-8000-000000000013,Cuentos de invierno,120,2,a1b2c3d4-0000-4000-8000-000000000003
  • The keys are real UUIDs. Values like 1 or 2 load fine into SQLite, but they are not UUIDs: they look nothing like the ones CAP generates on create and would end up mixed with them in the same table. Test data should have the same shape as real data.
  • The author association is filled through the author_ID column: CAP stores an association as a foreign key with the _ID suffix.

4. The service

srv/catalog-service.cds:

using biblioteca as db from '../db/schema';

service CatalogService {
  entity Authors as projection on db.Authors;

  entity Books as projection on db.Books {
    *,
    virtual null as available : Boolean
  };
}
  • The service projects the model’s entities, just like RAP’s C_ projection: it decides what this API exposes.
  • available is a virtual field: it shows up in the service but does not exist in the database. We compute it in code.

5. The logic in TypeScript

srv/catalog-service.ts. CAP links it automatically because it has the same name as the .cds file:

import cds from '@sap/cds';

export default class CatalogService extends cds.ApplicationService {
  async init() {
    const { Books } = this.entities;

    // Validar antes de escribir: al crear y al modificar
    this.before(['CREATE', 'UPDATE'], Books, (req) => {
      const { stock } = req.data;
      if (stock !== undefined && stock !== null && stock < 0) {
        req.error(400, 'El stock no puede ser negativo.', 'stock');
      }
    });

    // Completar el campo virtual después de leer
    this.after('READ', Books, (books) => {
      for (const book of Array.isArray(books) ? books : [books]) {
        if (book && book.stock !== undefined) book.available = book.stock > 0;
      }
    });

    return super.init();
  }
}

The two phases you will use most in CAP:

Phase What for Here
before Validate or complete the input before it reaches the database. req.error stops the request with an error Reject negative stock
after Adjust the result after reading Compute available

Three details that prevent subtle bugs:

  1. ['CREATE', 'UPDATE']: validating only on create leaves a gap, because a PATCH could set the stock to a negative value.
  2. req.error(400, message, 'stock'): the third argument is the affected field. A Fiori app uses it to highlight the right field in red, just like %element in RAP.
  3. book.stock !== undefined: if the client asks for only some fields ($select=title,available), stock is not in the result. Without that check, available would come back false even with copies in stock. This way it is simply left out.

6. Run it

cds watch

cds watch detects the TypeScript project, runs it without compiling, creates an in-memory SQLite database, loads the CSV files and restarts whenever you save a file. The service is available at http://localhost:4004/odata/v4/catalog.

7. Try it

Open http://localhost:4004 in the browser: CAP shows a page with links to each entity, to its $metadata, and to a Fiori preview generated on the fly.

You can also use curl:

Books with their availability:

curl "http://localhost:4004/odata/v4/catalog/Books?\$select=title,stock,available"

Ríos de montaña, with stock 0, comes back with "available": false; the rest with true.

An author with their books ($expand follows the association):

curl "http://localhost:4004/odata/v4/catalog/Authors?\$filter=country%20eq%20'CL'&\$expand=books(\$select=title)"

Try to create a book with negative stock:

curl -X POST http://localhost:4004/odata/v4/catalog/Books \
  -H "Content-Type: application/json" \
  -d '{"title":"Prueba","stock":-1}'

It answers 400 with "message": "El stock no puede ser negativo." and "target": "stock". The same happens with a PATCH on an existing book.

Creating a book without a title returns 400 with ASSERT_MANDATORY. That comes from @mandatory, without a single line of code.

Summary

  • db/: the model, with cuid and managed so you never repeat keys or administrative fields.
  • srv/*.cds: what the service exposes, including virtual fields.
  • srv/*.ts: the logic. before to validate the input, after to complete the result.
  • cds watch: a full OData V4 server, with test data, and nothing to configure.

Next step: add @UI annotations to generate a Fiori Elements app on top of this same service, and deploy it to BTP with SAP HANA Cloud instead of SQLite.