cds-validate
v1.0.0
Published
Zod validation middleware for SAP CAP — auto-validates request data with type coercion and OData-compliant error responses
Maintainers
Readme
🛡️ cds-validate
Zod-powered validation middleware for SAP CAP
Stop writing 50 lines of if-checks. One line. One schema. Done.
Installation • Quick Start • API Reference • Use Cases • Contributing
🤔 The Problem
Every SAP CAP developer writes the same boilerplate validation code over and over:
// ❌ This is what you're doing today — fragile, verbose, unmaintainable
srv.before('CREATE', 'Students', (req) => {
if (!req.data.name) req.error(400, 'Name is required');
if (!req.data.email) req.error(400, 'Email is required');
if (req.data.email && !req.data.email.includes('@')) req.error(400, 'Invalid email');
if (req.data.gpa && typeof req.data.gpa !== 'number') req.error(400, 'GPA must be a number');
if (req.data.gpa && (req.data.gpa < 0 || req.data.gpa > 4)) req.error(400, 'GPA must be 0-4');
// ... 20 more lines for each entity
});This doesn't scale. It doesn't compose. It doesn't give you type safety. And when you have 30 entities with 15 fields each, you're drowning in if-statements.
✨ The Solution
// ✅ One line. Type-safe. Auto-coercion. OData-compliant errors.
srv.before('CREATE', 'Students', validate(StudentSchema));cds-validate wraps your CAP handlers with Zod schema validation, giving you:
| Feature | What It Does |
|---|---|
| 🛡️ Schema validation | Validate req.data, req.query, and req.params with Zod |
| 🔄 Auto-partial on UPDATE | schema.partial() applied automatically for PATCH/UPDATE |
| 🎯 Type coercion | "3" → 3, trims whitespace, normalizes emails — for free |
| 📋 OData errors | Returns proper OData error responses (HTTP 422) with field details |
| 🏗️ Auto-schema generation | Build Zod schemas from your CDS model — zero manual work |
| 📦 Zero config | Works out of the box with any CAP project |
📦 Installation
# npm
npm install cds-validate zod
# pnpm
pnpm add cds-validate zod
# yarn
yarn add cds-validate zodPeer dependencies: Requires
@sap/cds(≥7.0.0) andzod(≥3.20.0) — you almost certainly have@sap/cdsalready.
Compatibility
| Environment | Version | |---|---| | Node.js | 18, 20, 22+ | | @sap/cds | ≥ 7.0.0 | | Zod | ≥ 3.20.0 | | TypeScript | ≥ 5.0 (optional) |
🚀 Quick Start
1. Define your schema
// srv/schemas.js
const { z } = require('zod');
const StudentSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().trim().email('Invalid email format'),
gpa: z.coerce.number().min(0).max(4).optional(),
enrolled: z.boolean().optional(),
});
module.exports = { StudentSchema };2. Use in your CAP service
// srv/student-service.js
const { validate } = require('cds-validate');
const { StudentSchema } = require('./schemas');
module.exports = (srv) => {
// CREATE — all required fields enforced
srv.before('CREATE', 'Students', validate(StudentSchema));
// UPDATE — only provided fields are validated (auto-partial)
srv.before('UPDATE', 'Students', validate(StudentSchema));
};3. That's it! ✅
Invalid requests now return clean OData errors:
{
"error": {
"code": 422,
"message": "Validation failed with 2 errors",
"target": "email",
"details": [
{ "message": "Invalid email format", "target": "email" },
{ "message": "Number must be less than or equal to 4", "target": "gpa" }
]
}
}🎯 Use Cases
When Should You Use cds-validate?
You have 30+ entities (Employees, Orders, Products, Invoices...) and need consistent validation across all of them. Instead of writing validation logic in each handler:
const { validate, schemaFromEntity } = require('cds-validate');
module.exports = async (srv) => {
// Auto-generate schemas from your CDS model
const EmployeeSchema = await schemaFromEntity('Employees');
const OrderSchema = await schemaFromEntity('Orders');
const ProductSchema = await schemaFromEntity('Products');
// Apply validation to all entities
for (const [name, schema] of Object.entries({
Employees: EmployeeSchema,
Orders: OrderSchema,
Products: ProductSchema,
})) {
srv.before('CREATE', name, validate(schema));
srv.before('UPDATE', name, validate(schema));
}
};When your CAP service acts as an API gateway receiving external payloads, you need strict validation to prevent garbage data from reaching your database:
const ExternalPayloadSchema = z.object({
transactionId: z.string().uuid(),
amount: z.coerce.number().positive(),
currency: z.enum(['USD', 'EUR', 'GBP', 'INR']),
timestamp: z.string().datetime(),
metadata: z.record(z.string()).optional(),
});
srv.before('CREATE', 'ExternalTransactions',
validate(ExternalPayloadSchema, { strict: true })
);Fiori Elements and UI5 understand OData error format natively. cds-validate returns errors in exactly this format, so your Fiori app automatically highlights the right fields:
const InvoiceSchema = z.object({
vendorId: z.string().uuid('Please select a valid vendor'),
amount: z.coerce.number().positive('Amount must be positive'),
invoiceDate: z.string().min(1, 'Invoice date is required'),
lineItems: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})).min(1, 'At least one line item required'),
});
// Fiori Elements will show field-level errors automatically
srv.before('CREATE', 'Invoices', validate(InvoiceSchema));const PromoteParams = z.object({
employeeId: z.string().uuid(),
newTitle: z.string().min(3).max(100),
salaryBump: z.coerce.number().min(0).max(50), // percentage
effectiveDate: z.string().datetime(),
});
srv.before('promoteEmployee', validateParams(PromoteParams));const ReadQuerySchema = z.object({
$top: z.coerce.number().max(100, 'Max 100 records per page').optional(),
$skip: z.coerce.number().min(0).optional(),
$orderby: z.string().optional(),
});
// Prevent clients from requesting 10,000 records at once
srv.before('READ', 'LargeDataSet', validateQuery(ReadQuerySchema));When NOT to Use It
| Scenario | Why Not | Alternative |
|---|---|---|
| Database constraints only | CDS @mandatory and DB constraints handle this | Use CDS annotations |
| Complex business rules | Validation across multiple entities/services | Write custom handler logic |
| Performance-critical hot paths | Adding Zod parsing overhead | Validate at API gateway level |
📖 API Reference
validate(schema, options?)
Creates middleware that validates
req.dataagainst a Zod schema.
function validate(schema: ZodSchema, options?: ValidationOptions): CAPHandler| Parameter | Type | Default | Description |
|---|---|---|---|
| schema | ZodSchema | required | The Zod schema to validate against |
| options.strict | boolean | false | Reject unknown keys not in the schema |
| options.normalize | boolean | true | Overwrite req.data with parsed output |
Key behaviors:
- CREATE → validates all fields as defined
- UPDATE / PATCH → auto-applies
schema.partial()so missing fields are allowed - normalize: true →
req.datais replaced with Zod-parsed output, giving you:- Type coercion:
z.coerce.number()converts"42"→42 - Transforms:
z.string().trim()strips whitespace - Defaults:
z.string().default('N/A')fills missing values
- Type coercion:
// Basic usage
srv.before('CREATE', 'Students', validate(StudentSchema));
// With options
srv.before('CREATE', 'Students', validate(StudentSchema, {
strict: true, // reject unknown fields like { hackerField: '...' }
normalize: false, // keep original req.data, don't overwrite with parsed
}));
// Works for UPDATE automatically — only validates fields that are present
srv.before('UPDATE', 'Students', validate(StudentSchema));
// Sending { name: 'New Name' } won't fail for missing email/gpavalidateQuery(schema, options?)
Creates middleware that validates
req.query— for OData query parameters.
function validateQuery(schema: ZodSchema, options?: ValidationOptions): CAPHandlerconst PaginationSchema = z.object({
$top: z.coerce.number().int().min(1).max(100).optional(),
$skip: z.coerce.number().int().min(0).optional(),
$orderby: z.string().optional(),
$filter: z.string().optional(),
$select: z.string().optional(),
});
srv.before('READ', 'Products', validateQuery(PaginationSchema));validateParams(schema, options?)
Creates middleware that validates
req.params— for bound action/function parameters.
function validateParams(schema: ZodSchema, options?: ValidationOptions): CAPHandlerHandles both object form ({ id: '...' }) and array form ([{ id: '...' }]) of req.params.
const TransferParams = z.object({
fromAccount: z.string().uuid(),
toAccount: z.string().uuid(),
amount: z.coerce.number().positive(),
});
srv.before('transferFunds', validateParams(TransferParams));schemaFromEntity(entityName, options?)
Auto-builds a Zod schema from a CDS entity definition. The killer feature.
async function schemaFromEntity(
entityName: string,
options?: SchemaFromEntityOptions
): Promise<ZodObject>| Parameter | Type | Default | Description |
|---|---|---|---|
| entityName | string | required | Short ('Students') or fully-qualified ('my.service.Students') |
| options.model | CDSModel | auto-loaded | Pre-loaded CDS model (skips cds.load()) |
| options.cdsFile | string | './' | Path to CDS source files |
How fields are mapped:
| CDS Type | → Zod Type | Notes |
|---|---|---|
| cds.String | z.string() | |
| cds.UUID | z.string().uuid() | |
| cds.Integer | z.number().int() | Also Int16, Int32, Int64 |
| cds.Decimal | z.coerce.number() | Coerces strings for safety |
| cds.Boolean | z.boolean() | |
| cds.Date | z.string() | Format: YYYY-MM-DD |
| cds.DateTime | z.string().datetime() | ISO 8601 |
| cds.Timestamp | z.string().datetime() | ISO 8601 |
| cds.LargeString | z.string() | No max length |
| Association | z.string().uuid() | Arrives as foreign key |
| Unknown | z.any() | Fallback — never crashes |
Field optionality rules:
| Condition | Result |
|---|---|
| @mandatory annotation | Required |
| key: true field | Optional (auto-generated by CAP) |
| Virtual / computed field | Skipped entirely |
| _-prefixed field | Skipped (internal CAP field) |
| Everything else | Optional |
const { schemaFromEntity, validate } = require('cds-validate');
module.exports = async (srv) => {
// Auto-generate — no manual schema writing needed!
const StudentSchema = await schemaFromEntity('Students');
srv.before('CREATE', 'Students', validate(StudentSchema));
srv.before('UPDATE', 'Students', validate(StudentSchema));
};mapZodError(zodError)
Low-level utility: converts a
ZodErrorinto a CAP OData error object.
function mapZodError(zodError: ZodError): CAPErrorUsed internally by validate(), but exported for custom validation flows:
const { mapZodError } = require('cds-validate');
srv.before('CREATE', 'Students', (req) => {
const result = MyCustomSchema.safeParse(req.data);
if (!result.success) {
const capError = mapZodError(result.error);
// capError = { code: 422, message: '...', target: 'email', details: [...] }
req.error(capError);
}
});cdsTypeToZod(element)
Converts a single CDS element definition to a Zod type. Useful for building custom schemas.
function cdsTypeToZod(element: CDSElement): ZodTypeAny🧩 TypeScript Support
Full first-class TypeScript support with exported types:
import {
// Functions
validate,
validateQuery,
validateParams,
schemaFromEntity,
mapZodError,
cdsTypeToZod,
// Types
type CAPRequest,
type CAPError,
type CAPErrorDetail,
type ValidationOptions,
type CAPHandler,
type ValidateSchema,
type CDSElement,
type CDSEntity,
type CDSModel,
type SchemaFromEntityOptions,
} from 'cds-validate';🧪 Error Response Format
All validation errors follow the OData JSON error format:
// Single field error
{
"error": {
"code": 422,
"message": "Invalid email format",
"target": "email",
"details": [
{ "message": "Invalid email format", "target": "email" }
]
}
}
// Multiple field errors
{
"error": {
"code": 422,
"message": "Validation failed with 3 errors",
"target": "name",
"details": [
{ "message": "Name is required", "target": "name" },
{ "message": "Invalid email format", "target": "email" },
{ "message": "Number must be at most 4", "target": "gpa" }
]
}
}
// Nested field error
{
"error": {
"code": 422,
"message": "Zip must be 5 characters",
"target": "address/zipCode",
"details": [
{ "message": "Zip must be 5 characters", "target": "address/zipCode" }
]
}
}The target field uses /-separated paths — this is standard OData syntax.
📋 Advanced Examples
Conditional validation with refinements
const OrderSchema = z.object({
type: z.enum(['standard', 'express', 'overnight']),
amount: z.coerce.number().positive(),
deliveryDate: z.string().datetime().optional(),
}).refine(
(data) => data.type !== 'express' || data.deliveryDate != null,
{ message: 'Express orders require a delivery date', path: ['deliveryDate'] }
);Reusable field schemas
const Email = z.string().trim().toLowerCase().email('Invalid email');
const UUID = z.string().uuid('Invalid ID format');
const Money = z.coerce.number().positive().multipleOf(0.01);
const InvoiceSchema = z.object({
vendorEmail: Email,
vendorId: UUID,
amount: Money,
currency: z.enum(['USD', 'EUR', 'GBP']),
});Combining auto-generated and manual schemas
module.exports = async (srv) => {
// Start from CDS model...
const baseSchema = await schemaFromEntity('Students');
// ...then extend with custom rules
const strictSchema = baseSchema.extend({
email: z.string().trim().toLowerCase().email(),
gpa: z.coerce.number().min(0).max(4),
});
srv.before('CREATE', 'Students', validate(strictSchema));
};🏗️ Architecture
┌──────────────────────────────────────────────────────────┐
│ Your CAP Service Handler │
│ srv.before('CREATE', 'Students', validate(schema)) │
└──────────────────────────┬───────────────────────────────┘
│
┌──────▼──────┐
│ validator │ ← Higher-order middleware factory
│ │ Detects event type (CREATE/UPDATE)
│ │ Applies .partial() for UPDATE
└──────┬──────┘
│
┌───────────┴───────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Zod Schema │ │ error-mapper │
│ .safeParse() │ │ │
│ │ │ ZodError → │
│ ✅ success │ │ OData 422 │
│ → normalize │ │ { code, │
│ req.data │ │ message, │
│ │ │ target, │
│ ❌ failure ─────────▶ details } │
└──────────────┘ └──────┬──────┘
│
┌──────▼──────┐
│ req.error() │
│ CAP handles │
│ the rest │
└─────────────┘📦 What's Included
cds-validate/
├── dist/
│ ├── index.js ← CommonJS entry point
│ ├── index.d.ts ← TypeScript declarations
│ ├── validator.js
│ ├── error-mapper.js
│ ├── schema-builder.js
│ └── types.js
├── README.md
└── LICENSEPackage size: ~13 KB (packed)
🔄 Version History
See CHANGELOG.md for the full release history.
| Version | Date | Highlights |
|---|---|---|
| 1.0.0 | 2026-04-16 | 🎉 Initial release — validate(), validateQuery(), validateParams(), schemaFromEntity(), mapZodError(), cdsTypeToZod() |
Versioning Strategy
This project follows Semantic Versioning:
| Change | When | Example |
|---|---|---|
| Patch (1.0.x) | Bug fixes, no API changes | 1.0.0 → 1.0.1 |
| Minor (1.x.0) | New features, backwards compatible | 1.0.0 → 1.1.0 |
| Major (x.0.0) | Breaking API changes | 1.0.0 → 2.0.0 |
🤝 Contributing
Contributions are welcome! Here's how to get started:
# 1. Clone the monorepo
git clone https://github.com/YOUR_USERNAME/cds-validate.git
cd cds-oss-libs
# 2. Install dependencies
pnpm install
# 3. Run tests
cd packages/cds-validate
pnpm test # Run all 56 tests
pnpm test:watch # Watch mode
pnpm test -- --coverage # With coverage report
# 4. Type-check
pnpm lint # tsc --noEmit
# 5. Build
pnpm build # Compile to dist/Development Workflow
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Write tests first, then implementation
- Ensure all tests pass:
pnpm test - Ensure types are clean:
pnpm lint - Submit a Pull Request
📄 License
MIT — use it freely in personal and commercial projects.
Built with ❤️ for the SAP CAP community
If this saved you time, ⭐ the repo and share it!
