Building Full-Stack SAP CAP Applications: Node.js + UI5 Masterclass
SAP Cloud Application Programming (CAP) model combines the best of modern development: declarative data models, powerful Node.js services, and seamless SAP UI5 integration. This comprehensive guide will walk you through building a complete full-stack application from scratch.
What is SAP CAP?
Core Components
| Component | Technology | Purpose |
|---|---|---|
| CDS Models | CDS (Core Data Services) | Define data models and services |
| Service Layer | Node.js / Java | Business logic and APIs |
| Database | SQLite / HANA / PostgreSQL | Data persistence |
| UI Layer | SAP UI5 / Fiori Elements | User interface |
| Authentication | XSUAA / Mock auth | Security and authorization |
Project Setup
Step 1: Initialize CAP Project
# Install CAP CLI globally npm install -g @sap/cds-dk # Verify installation cds version # Create new project cds init bookshop --add tiny-sample # Project structure created: # bookshop/ # ├── app/ # UI applications # ├── db/ # Database models # ├── srv/ # Service definitions & implementations # ├── package.json # └── README.md
Step 2: Define Data Model
// db/schema.cds
namespace bookshop;
entity Books {
key ID : UUID;
title : String(100);
author : String(100);
stock : Integer;
price : Decimal(10, 2);
genre : String(50);
ISBN : String(20);
}
entity Customers {
key ID : UUID;
firstName : String(50);
lastName : String(50);
email : String(100);
phone : String(20);
creditLimit : Decimal(10, 2);
orders : Association to many Orders on orders.customer = $self;
}
entity Orders {
key ID : UUID;
customer : Association to Customers;
book : Association to Books;
quantity : Integer;
orderDate : DateTime;
status : String(20) @assert.range enum {
PENDING;
CONFIRMED;
SHIPPED;
DELIVERED;
CANCELLED;
};
totalAmount : Decimal(10, 2);
}Step 3: Define Services
// srv/catalog-service.cds
using bookshop from '../db/schema';
service CatalogService @(path: '/browse') {
@readonly entity Books as projection on bookshop.Books {
*, genre as category
} excluding { ISBN };
entity Customers as projection on bookshop.Customers;
entity Orders as projection on bookshop.Orders;
// Custom actions
action submitOrder(book: UUID, quantity: Integer) returns Orders;
function getBestSellers() returns array of Books;
action cancelOrder(order: UUID) returns Boolean;
}Step 4: Implement Service Logic
// srv/catalog-service.js
const cds = require('@sap/cds');
module.exports = cds.service.impl(async function() {
const { Books, Orders, Customers } = this.entities;
// Before CREATE: Validate stock availability
this.before('CREATE', 'Orders', async (req) => {
const { book, quantity } = req.data;
const bookEntity = await SELECT.one.from(Books).where({ ID: book });
if (!bookEntity) {
return req.error(404, `Book with ID ${book} not found`);
}
if (bookEntity.stock < quantity) {
return req.error(400, `Insufficient stock. Available: ${bookEntity.stock}`);
}
// Calculate total amount
req.data.totalAmount = bookEntity.price * quantity;
req.data.orderDate = new Date().toISOString();
req.data.status = 'PENDING';
});
// After CREATE: Update book stock
this.after('CREATE', 'Orders', async (data, req) => {
const { book, quantity } = data;
await UPDATE(Books)
.set({ stock: { '-=': quantity } }) // Decrement stock
.where({ ID: book });
console.log(`Order ${data.ID} created. Stock updated for book ${book}`);
});
// Custom action: submitOrder
this.on('submitOrder', async (req) => {
const { book, quantity } = req.data;
const user = req.user;
// Get customer by user email
const customer = await SELECT.one.from(Customers)
.where({ email: user.id });
if (!customer) {
return req.error(404, 'Customer not found. Please register first.');
}
// Create order
const order = await INSERT.into(Orders).entries({
customer_ID: customer.ID,
book_ID: book,
quantity: quantity
});
return order;
});
// Custom function: getBestSellers
this.on('getBestSellers', async (req) => {
const bestSellers = await SELECT.from(Books)
.columns('ID', 'title', 'author', 'price')
.where({ stock: { '>': 0 } })
.orderBy('stock desc')
.limit(10);
return bestSellers;
});
// Custom action: cancelOrder
this.on('cancelOrder', async (req) => {
const { order } = req.data;
const orderEntity = await SELECT.one.from(Orders).where({ ID: order });
if (!orderEntity) {
return req.error(404, 'Order not found');
}
if (orderEntity.status === 'DELIVERED') {
return req.error(400, 'Cannot cancel delivered order');
}
// Update order status
await UPDATE(Orders)
.set({ status: 'CANCELLED' })
.where({ ID: order });
// Restore book stock
await UPDATE(Books)
.set({ stock: { '+=': orderEntity.quantity } })
.where({ ID: orderEntity.book_ID });
return true;
});
// Authorization: Restrict customer data
this.before('READ', 'Customers', async (req) => {
const user = req.user;
if (!user.is('admin')) {
// Non-admin users can only see their own data
req.query.where({ email: user.id });
}
});
});Add Sample Data
// db/data/bookshop-Books.csv ID;title;author;stock;price;genre;ISBN 1;1984;George Orwell;25;15.99;Fiction;978-0451524935 2;To Kill a Mockingbird;Harper Lee;30;14.99;Fiction;978-0061120084 3;The Great Gatsby;F. Scott Fitzgerald;20;12.99;Fiction;978-0743273565 4;Clean Code;Robert C. Martin;15;42.99;Technology;978-0132350884 5;Design Patterns;Gang of Four;10;54.99;Technology;978-0201633610 // db/data/bookshop-Customers.csv ID;firstName;lastName;email;phone;creditLimit c1;John;Doe;john.doe@example.com;+1-555-0001;5000.00 c2;Jane;Smith;jane.smith@example.com;+1-555-0002;7500.00 c3;Bob;Johnson;bob.johnson@example.com;+1-555-0003;3000.00
Run and Test Backend
# Install dependencies
npm install
# Run in development mode (with SQLite)
cds watch
# Server starts at http://localhost:4004
# Service endpoints:
# - http://localhost:4004/browse/Books
# - http://localhost:4004/browse/Orders
# - http://localhost:4004/browse/Customers
# Test with REST client
# GET Books
curl http://localhost:4004/browse/Books
# GET Single Book
curl http://localhost:4004/browse/Books/1
# POST Create Order
curl -X POST http://localhost:4004/browse/Orders \
-H "Content-Type: application/json" \
-d '{
"customer_ID": "c1",
"book_ID": "1",
"quantity": 2
}'
# POST Submit Order (custom action)
curl -X POST http://localhost:4004/browse/submitOrder \
-H "Content-Type: application/json" \
-d '{
"book": "1",
"quantity": 1
}'
# GET Best Sellers (custom function)
curl http://localhost:4004/browse/getBestSellers()Build SAP UI5 Frontend
Step 1: Add UI5 Application
# Generate UI5 app with Fiori tools
cds add fiori
# Or manually create app folder
mkdir -p app/bookshop
# Create manifest.json
# app/bookshop/webapp/manifest.json
{
"sap.app": {
"id": "bookshop",
"type": "application",
"title": "Bookshop",
"dataSources": {
"mainService": {
"uri": "/browse/",
"type": "OData",
"settings": {
"odataVersion": "4.0"
}
}
}
},
"sap.ui5": {
"models": {
"": {
"dataSource": "mainService",
"settings": {
"synchronizationMode": "None",
"operationMode": "Server",
"autoExpandSelect": true
}
}
},
"routing": {
"routes": [
{
"pattern": "",
"name": "BookList",
"target": "BookList"
},
{
"pattern": "book/{bookId}",
"name": "BookDetail",
"target": "BookDetail"
}
],
"targets": {
"BookList": {
"viewName": "BookList",
"viewLevel": 1
},
"BookDetail": {
"viewName": "BookDetail",
"viewLevel": 2
}
}
}
}
}Step 2: Create Book List View
<!-- app/bookshop/webapp/view/BookList.view.xml -->
<mvc:View
controllerName="bookshop.controller.BookList"
xmlns:mvc="sap.ui.core.mvc"
xmlns="sap.m"
xmlns:f="sap.f"
displayBlock="true">
<Page title="Bookshop - Browse Books">
<content>
<List
id="bookList"
items="{/Books}"
mode="SingleSelectMaster"
selectionChange=".onBookSelect">
<StandardListItem
title="{title}"
description="{author}"
info="{price}"
infoState="{= ${stock} > 10 ? 'Success' : 'Warning' }"
type="Navigation">
<customData>
<core:CustomData key="bookId" value="{ID}" />
</customData>
</StandardListItem>
</List>
</content>
<footer>
<Toolbar>
<ToolbarSpacer />
<Button
text="View Best Sellers"
press=".onViewBestSellers"
type="Emphasized" />
</Toolbar>
</footer>
</Page>
</mvc:View>Step 3: Create Book List Controller
// app/bookshop/webapp/controller/BookList.controller.js
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/m/MessageToast",
"sap/m/MessageBox"
], function(Controller, MessageToast, MessageBox) {
"use strict";
return Controller.extend("bookshop.controller.BookList", {
onInit: function() {
// Initialize
},
onBookSelect: function(oEvent) {
const oItem = oEvent.getParameter("listItem");
const bookId = oItem.data("bookId");
// Navigate to detail page
this.getOwnerComponent().getRouter().navTo("BookDetail", {
bookId: bookId
});
},
onViewBestSellers: function() {
const oModel = this.getView().getModel();
// Call custom function
oModel.callFunction("/getBestSellers", {
method: "GET",
success: (oData) => {
MessageBox.information(
`Top sellers: ${oData.value.map(b => b.title).join(', ')}`
);
},
error: (oError) => {
MessageBox.error("Failed to load best sellers");
}
});
}
});
});Step 4: Create Book Detail View
<!-- app/bookshop/webapp/view/BookDetail.view.xml -->
<mvc:View
controllerName="bookshop.controller.BookDetail"
xmlns:mvc="sap.ui.core.mvc"
xmlns="sap.m"
xmlns:f="sap.ui.layout.form">
<Page
title="{title}"
showNavButton="true"
navButtonPress=".onNavBack">
<content>
<ObjectHeader
title="{title}"
number="{price}"
numberUnit="USD"
intro="{author}">
<attributes>
<ObjectAttribute title="Genre" text="{genre}" />
<ObjectAttribute title="Stock" text="{stock}" />
<ObjectAttribute title="ISBN" text="{ISBN}" />
</attributes>
</ObjectHeader>
<f:SimpleForm
editable="true"
layout="ResponsiveGridLayout">
<f:content>
<Label text="Quantity" />
<StepInput
id="quantityInput"
value="1"
min="1"
max="{stock}"
width="150px" />
<Button
text="Order Book"
press=".onOrderBook"
type="Emphasized" />
</f:content>
</f:SimpleForm>
</content>
</Page>
</mvc:View>Step 5: Create Book Detail Controller
// app/bookshop/webapp/controller/BookDetail.controller.js
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/m/MessageToast",
"sap/m/MessageBox"
], function(Controller, MessageToast, MessageBox) {
"use strict";
return Controller.extend("bookshop.controller.BookDetail", {
onInit: function() {
const oRouter = this.getOwnerComponent().getRouter();
oRouter.getRoute("BookDetail").attachMatched(this._onRouteMatched, this);
},
_onRouteMatched: function(oEvent) {
const bookId = oEvent.getParameter("arguments").bookId;
// Bind view to selected book
this.getView().bindElement({
path: `/Books(${bookId})`
});
},
onNavBack: function() {
this.getOwnerComponent().getRouter().navTo("BookList");
},
onOrderBook: async function() {
const oView = this.getView();
const oModel = oView.getModel();
const oContext = oView.getBindingContext();
const bookId = oContext.getProperty("ID");
const quantity = this.byId("quantityInput").getValue();
// Call custom action: submitOrder
const oAction = oModel.bindContext(`/submitOrder(...)`);
oAction.setParameter("book", bookId);
oAction.setParameter("quantity", parseInt(quantity));
try {
await oAction.execute();
const oResult = oAction.getBoundContext().getObject();
MessageBox.success(
`Order ${oResult.ID} placed successfully!\nTotal: $${oResult.totalAmount}`,
{
onClose: () => {
// Refresh book data to show updated stock
oContext.refresh();
}
}
);
} catch (error) {
MessageBox.error(`Order failed: ${error.message}`);
}
}
});
});Authentication and Authorization
Add Mock Users for Development
// .cdsrc.json
{
"requires": {
"auth": {
"kind": "mocked",
"users": {
"john.doe@example.com": {
"password": "password",
"ID": "john.doe@example.com",
"roles": ["customer"]
},
"admin@bookshop.com": {
"password": "admin",
"ID": "admin@bookshop.com",
"roles": ["admin"]
}
}
}
}
}Add Authorization Annotations
// srv/catalog-service.cds
using bookshop from '../db/schema';
service CatalogService @(path: '/browse') @(requires: 'authenticated-user') {
@readonly entity Books as projection on bookshop.Books;
@restrict: [
{ grant: 'READ', to: 'authenticated-user' },
{ grant: ['CREATE', 'UPDATE', 'DELETE'], to: 'admin' }
]
entity Customers as projection on bookshop.Customers;
@restrict: [
{ grant: 'READ', to: 'authenticated-user', where: 'customer.email = $user' },
{ grant: 'CREATE', to: 'authenticated-user' },
{ grant: '*', to: 'admin' }
]
entity Orders as projection on bookshop.Orders;
action submitOrder(book: UUID, quantity: Integer) returns Orders;
}Deploy to SAP BTP
Add Cloud Configuration
# mta.yaml
_schema-version: "3.1"
ID: bookshop
version: 1.0.0
modules:
- name: bookshop-srv
type: nodejs
path: gen/srv
requires:
- name: bookshop-db
provides:
- name: srv-api
properties:
srv-url: ${default-url}
- name: bookshop-db-deployer
type: hdb
path: gen/db
requires:
- name: bookshop-db
resources:
- name: bookshop-db
type: com.sap.xs.hdi-container
properties:
hdi-service-name: ${service-name}
- name: bookshop-uaa
type: org.cloudfoundry.managed-service
parameters:
service: xsuaa
service-plan: applicationBuild and Deploy
# Build MTA archive cds build --production mbt build # Deploy to Cloud Foundry cf deploy mta_archives/bookshop_1.0.0.mtar # Or deploy to Kyma kubectl apply -f deployment.yaml
Testing
Unit Tests for Service
// test/catalog-service.test.js
const cds = require('@sap/cds/lib');
const { expect } = require('chai');
describe('CatalogService', () => {
let catalogService;
before(async () => {
catalogService = await cds.connect.to('CatalogService');
});
it('should read books', async () => {
const { Books } = catalogService.entities;
const books = await SELECT.from(Books);
expect(books).to.have.length.greaterThan(0);
});
it('should create order with sufficient stock', async () => {
const { Orders } = catalogService.entities;
const order = await INSERT.into(Orders).entries({
customer_ID: 'c1',
book_ID: '1',
quantity: 1
});
expect(order).to.have.property('ID');
});
it('should reject order with insufficient stock', async () => {
const { Orders } = catalogService.entities;
try {
await INSERT.into(Orders).entries({
customer_ID: 'c1',
book_ID: '1',
quantity: 1000
});
expect.fail('Should have thrown error');
} catch (error) {
expect(error.message).to.include('Insufficient stock');
}
});
});Best Practices
| Practice | Description |
|---|---|
| Use CDS Annotations | Leverage @readonly, @restrict for declarative security |
| Implement Before/After Handlers | Validation in before, side effects in after |
| Custom Actions for Complex Logic | Use actions/functions for business operations |
| Proper Error Handling | Use req.error() with meaningful messages |
| Test Coverage | Unit tests for services, integration tests for flows |
Conclusion
SAP CAP provides a modern, productive framework for full-stack development:
- ✅ CDS for declarative data modeling
- ✅ Node.js for powerful backend logic
- ✅ SAP UI5 for enterprise-grade UI
- ✅ Built-in security and authorization
- ✅ Cloud-ready deployment
SAP CAP bridges SAP heritage with modern cloud-native development practices.
