cap-handler-framework
v1.1.2
Published
Handler framework for SAP CAP applications with auto-generation, multi-service support, TypeScript, and draft lifecycle
Maintainers
Readme
cap-handler-framework
Handler framework for SAP CAP applications — convention-based, TypeScript-first, draft-aware.
✨ Features
- ✅ Convention-based — auto-maps methods like
beforeCreate,onRead,afterUpdate - ✅ Correct draft lifecycle — explicit hooks for NEW/PATCH/EDIT/SAVE/DISCARD, separated from active entity hooks
- ✅ Actions & functions — bound and unbound operations with clear naming (
onBoundAction_,onUnboundAction_, …) - ✅ Multi-service — support for multiple CAP services in one project
- ✅ Type-safe — full TypeScript support
- ✅ Performance —
ExpandTreeoptimization (50–80% fewer remote calls) - ✅ Auto-generation — CDS plugin generates
handlers/index.tsautomatically - ✅ Watch support —
cds watchtriggers index regeneration without infinite reload loops - ✅ Dependency injection — shared context for external services and utilities
- ✅ Local dev — npm workspace setup for framework development without publishing
📦 Installation
npm install cap-handler-framework🚀 Quick start
1. Create a handler
// srv/my-service/handlers/entities/BooksHandler.ts
import { BaseHandler } from 'cap-handler-framework';
import type { TypedRequest } from 'cap-handler-framework';
export default class BooksHandler extends BaseHandler {
getEntityName() { return 'Books'; }
async beforeCreate(req: TypedRequest): Promise<void> {
req.data.createdAt = new Date().toISOString();
}
async onRead(req: TypedRequest, next: () => Promise<any>): Promise<any> {
this.initializeExpandTree(req);
const result = await next();
if (this.isExpanded('author')) {
await this.enrichAuthor(result);
}
return result;
}
}2. Register handlers in your service
// srv/my-service.ts
import { ApplicationService } from '@sap/cds';
import { registerHandlers } from 'cap-handler-framework';
import { HANDLER_CLASSES } from './my-service/handlers';
export class MyService extends ApplicationService {
async init() {
await registerHandlers(this, { handlerClasses: HANDLER_CLASSES });
return super.init();
}
}3. Start the server
cds watchThe HANDLER_CLASSES import is auto-generated. ✅
🎯 Active entity hooks
| Method | Phase | CAP event | Registers on |
|--------|-------|-----------|--------------|
| beforeCreate | before | CREATE | entity |
| afterCreate | after | CREATE | entity |
| beforeRead | before | READ | entity |
| onRead | on | READ | entity |
| afterRead | after | READ | entity (+ entity.drafts if draft-enabled) |
| beforeUpdate | before | UPDATE | entity |
| afterUpdate | after | UPDATE | entity |
| beforeDelete | before | DELETE | entity |
| afterDelete | after | DELETE | entity |
beforeCreatealso fires when a draft is activated (SAVE → INSERT on active entity). This is correct CAP behaviour.
🗂️ Draft lifecycle hooks
Enable draft support in your handler:
shouldHandleDrafts(): boolean { return true; }| Method | Phase | CAP event | Registers on |
|--------|-------|-----------|--------------|
| beforeNewDraft | before | NEW | entity (active) |
| afterNewDraft | after | NEW | entity |
| beforeCreateDraft | before | CREATE | entity.drafts |
| afterCreateDraft | after | CREATE | entity.drafts |
| beforePatchDraft | before | PATCH | entity.drafts |
| afterPatchDraft | after | PATCH | entity.drafts |
| beforeEditDraft | before | EDIT | entity (active) |
| afterEditDraft | after | EDIT | entity |
| beforeSaveDraft | before | SAVE | entity.drafts |
| afterSaveDraft | after | SAVE | entity.drafts |
| beforeDiscardDraft | before | CANCEL | entity.drafts |
| afterDiscardDraft | after | CANCEL | entity.drafts |
beforeEditDraftandbeforeNewDraftfire on the active entity — CAP fires NEW and EDIT on the active entity, not on the drafts table.
export default class TradeSlipsHandler extends BaseHandler {
getEntityName() { return 'TradeSlips'; }
shouldHandleDrafts() { return true; }
// Fires during draft activation (SAVE → CREATE on active entity)
async beforeCreate(req: TypedRequest): Promise<void> {
req.data.tradeSlipIndex = await this.sequenceManager.nextIndex();
}
// User changed a field in the draft form
async afterPatchDraft(data: any, req: TypedRequest): Promise<void> {
await this.autoFillDeliveryAddress(this.toArray(data)[0], req);
}
// Final validation before activation
async beforeSaveDraft(req: TypedRequest): Promise<void> {
if (!req.data.customerNumber) req.error(400, 'Customer is required');
}
// User clicked "Discard"
async beforeDiscardDraft(req: TypedRequest): Promise<void> {
this.logger.info('Draft discarded');
}
}⚡ Actions and functions
Naming convention
| Method prefix | Registers as |
|--------------|-------------|
| onBoundAction_<Name> | srv.on('<Name>', entity, handler) |
| onUnboundAction_<Name> | srv.on('<Name>', handler) |
| onBoundFunction_<Name> | srv.on('<Name>', entity, handler) |
| onUnboundFunction_<Name> | srv.on('<Name>', handler) |
| on<Name> (legacy) | auto-detected from CDS model |
Bound action example
// CDS definition
entity TradeSlips ... actions {
action DuplicateTradeSlip() returns TradeSlips;
};// Handler
async onBoundAction_DuplicateTradeSlip(req: TypedRequest): Promise<any> {
const { ID } = req.params[0] as any; // entity key
const tx = this.tx(req);
// ... duplicate logic ...
return copy;
}POST /odata/v4/opportunity-management/TradeSlips(ID=550e8400...)/DuplicateTradeSlipUnbound action example
// CDS definition
service OpportunityManagementService {
action CreateWithReference(quote_ID: UUID) returns String;
}// Handler
async onUnboundAction_CreateWithReference(req: TypedRequest): Promise<any> {
const { quote_ID } = req.data;
// ... create from reference ...
return `Created from quote ${quote_ID}`;
}POST /odata/v4/opportunity-management/CreateWithReference
{ "quote_ID": "..." }🔌 External services
await registerHandlers(this, {
handlerClasses: HANDLER_CLASSES,
externalServices: ['API_BUSINESS_PARTNER', 'API_PRODUCT_SRV'],
utilities: { sequenceManager: new SequenceManager() },
});In the handler:
const bpApi = this.getExternalService('API_BUSINESS_PARTNER');
const result = await bpApi.run(SELECT.from('A_BusinessPartner').where({ ... }));🏗️ Project structure
srv/
└── opportunity-management/
├── handlers/
│ ├── index.ts ← AUTO-GENERATED by cds-plugin
│ ├── entities/
│ │ ├── TradeSlipsHandler.ts
│ │ └── TradeSlipItemHandler.ts
│ └── proxies/
│ └── BusinessPartnersProxyHandler.ts
└── utils/
└── SequenceManager.ts📖 Documentation
| Document | Topic | |----------|-------| | docs/HOOKS.md | Active entity lifecycle hooks | | docs/DRAFTS.md | Draft lifecycle — NEW, PATCH, EDIT, SAVE, DISCARD | | docs/ACTIONS_AND_FUNCTIONS.md | Bound/unbound actions and functions | | docs/HANDLER_INDEX_GENERATION.md | CDS plugin, safe write, file watcher | | docs/LOCAL_DEVELOPMENT.md | npm workspace local dev without publishing |
🛠️ Local development (without npm publishing)
The framework and the CAP project share an npm workspace at the repo root:
# From repo root
npm install # creates symlinks
cd cap-handler-framework && npm run watch # compile on change
cd my-cap-project && cds-ts watch # CAP dev serverChanges to the framework compile immediately and cds watch picks them up.
See docs/LOCAL_DEVELOPMENT.md for full details.
📝 License
MIT
