s4kit
v0.1.12
Published
The lightweight, type-safe SDK for SAP S/4HANA
Downloads
36
Maintainers
Readme
S4Kit SDK
Type-safe SDK for consuming SAP APIs. Build Clean Core applications faster.
Works with Next.js, Express, Hono, Fastify, NestJS, Remix, and any Node.js framework.
Documentation · Get API Key · Examples
npm install s4kit # or yarn add s4kit / pnpm add s4kit / bun add s4kitQuick Start
import { S4Kit } from 's4kit';
const client = S4Kit({ apiKey: 'sk_live_...' });
const partners = await client.A_BusinessPartner.list({
filter: { BusinessPartnerCategory: '1' },
top: 10
});Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| apiKey | string | required | Your S4Kit API key |
| baseUrl | string | https://api.s4kit.com/api/proxy | API endpoint |
| connection | string | - | Default SAP connection alias |
| timeout | number | 30000 | Request timeout (ms) |
| retries | number | 0 | Retry failed requests |
| debug | boolean | false | Enable debug logging |
How It Works
The SDK connects to S4Kit Platform — a proxy service that handles the complexity of SAP integration:
Your App → S4Kit SDK → S4Kit Platform → Your SAP System
├─ API key authentication
├─ Connection management
├─ Rate limiting
└─ Request loggingGetting started:
- Create an account at app.s4kit.com
- Connect your SAP system (S/4HANA, BTP, or CAP service)
- Generate an API key
- Use the API key in your SDK configuration
The platform handles CSRF tokens, authentication, and connection pooling — you just write clean TypeScript.
Type Generation (Recommended)
This is the key feature of S4Kit. Generate TypeScript types directly from your SAP system's OData metadata for full autocomplete and compile-time type safety.
Why Generate Types?
Without types, the SDK works but you lose the main benefit - type safety:
// Without types - works but no autocomplete, no type checking
const partners = await client.A_BusinessPartner.list(); // partners is any[]With generated types:
// With types - full IDE support and compile-time validation
const partners = await client.A_BusinessPartner.list({
select: ['BusinessPartner', 'BusinessPartnerName'], // ← Autocomplete!
});
partners.forEach(p => console.log(p.BusinessPartnerName)); // ← Type-safe!Generating Types
# Basic usage
npx s4kit generate-types --api-key sk_live_... --output ./types
# With all options
npx s4kit generate-types \
--api-key sk_live_... # Required
--output ./types # Output directory (default: ./s4kit-types)
--base-url https://api.s4kit.com/api/proxy # Custom proxy URL
--connection my-sap-system # Specific connection onlyUsing Generated Types
import { S4Kit } from 's4kit';
import './types'; // ← This enables type inference
const client = S4Kit({ apiKey: 'sk_live_...' });
// Full autocomplete on entity names and fields
const partners = await client.A_BusinessPartner.list({
select: ['BusinessPartner', 'BusinessPartnerName'],
filter: { BusinessPartnerCategory: '1' }
});
// partners is A_BusinessPartner[], not any[]
partners.forEach(p => console.log(p.BusinessPartnerName));What You Get
- Entity autocomplete -
client.shows all available entities - Field autocomplete -
select,filter,orderByshow valid fields - Type-safe filters - operators match field types (string fields get
contains, number fields getgt/lt) - Proper return types - query results are typed, not
any[] - Navigation properties -
expandoptions show available relations - Compile-time errors - typos in field names caught before runtime
Regenerating Types
Regenerate types when:
- You connect a new SAP service
- The SAP system's schema changes
- You add new services to your API key
# Regenerate all types
npx s4kit generate-types --api-key sk_live_... --output ./typesSAP S/4HANA Examples
Reading Data
// List with filtering and pagination
const partners = await client.A_BusinessPartner.list({
filter: {
BusinessPartnerCategory: '1',
CreationDate: { gt: '2024-01-01' }
},
select: ['BusinessPartner', 'BusinessPartnerName', 'Industry'],
orderBy: { BusinessPartnerName: 'asc' },
top: 50,
skip: 0
});
// Get single entity
const partner = await client.A_BusinessPartner.get('1000000');
// Get with expanded relations
const order = await client.A_SalesOrder.get('12345', {
expand: {
to_Item: {
select: ['SalesOrderItem', 'Material', 'NetAmount'],
orderBy: { SalesOrderItem: 'asc' }
}
}
});
// Count
const total = await client.A_BusinessPartner.count();
const active = await client.A_BusinessPartner.count({
filter: { BusinessPartnerCategory: '1' }
});
// List with count
const { value, count } = await client.A_BusinessPartner.listWithCount({
top: 20
});
console.log(`Showing ${value.length} of ${count}`);Creating Data
// Single create
const partner = await client.A_BusinessPartner.create({
BusinessPartnerCategory: '1',
BusinessPartnerFullName: 'Acme Corporation'
});
// Batch create
const products = await client.A_Product.createMany([
{ Product: 'PROD001', ProductType: 'FINISHED' },
{ Product: 'PROD002', ProductType: 'FINISHED' },
{ Product: 'PROD003', ProductType: 'FINISHED' }
]);Updating Data
// Partial update (PATCH)
await client.A_BusinessPartner.update('1000000', {
BusinessPartnerFullName: 'Updated Name'
});
// Full replacement (PUT)
await client.A_BusinessPartner.replace('1000000', {
BusinessPartnerCategory: '1',
BusinessPartnerFullName: 'Complete Replacement'
});
// Upsert (create or update)
await client.A_Product.upsert({
Product: 'PROD001',
ProductType: 'FINISHED',
StandardPrice: 99.99
});Deleting Data
// Single delete
await client.A_BusinessPartner.delete('1000000');
// Batch delete
await client.A_Product.deleteMany(['PROD001', 'PROD002', 'PROD003']);CAP Bookshop Examples
Examples using the SAP CAP Bookshop sample service:
Full CRUD Cycle
const client = S4Kit({ apiKey: 'sk_live_...' });
// List books with filtering
const cheapBooks = await client.Books.list({
filter: { price: { lt: 15 } },
orderBy: { price: 'asc' },
select: ['title', 'author', 'price']
});
// Create author
const author = await client.Authors.create({
name: 'Ada Lovelace',
dateOfBirth: '1815-12-10',
placeOfBirth: 'London'
});
// Update author
await client.Authors.update(author.ID, {
dateOfDeath: '1852-11-27',
placeOfDeath: 'London'
});
// Delete author
await client.Authors.delete(author.ID);Deep Insert (Composition)
Create an entity with nested related entities in a single request.
Note:
createDeep()only works with Composition relationships. For Association relationships, nested data is silently ignored - use separatecreate()calls or transactions instead.
// Book with localized texts (Composition relationship)
const book = await client.Books.createDeep({
title: "The Hitchhiker's Guide",
price: 42.00,
author_ID: 101,
genre_ID: 'fiction',
texts: [
{ locale: 'de', title: 'Per Anhalter durch die Galaxis' },
{ locale: 'fr', title: 'Le Guide du voyageur galactique' }
]
});Batch Operations
// Create multiple books
const books = await client.Books.createMany([
{ title: 'Book 1', author_ID: 101, genre_ID: 'fiction', price: 9.99 },
{ title: 'Book 2', author_ID: 101, genre_ID: 'fiction', price: 14.99 },
{ title: 'Book 3', author_ID: 101, genre_ID: 'fiction', price: 19.99 }
]);
// Delete multiple books
await client.Books.deleteMany(books.map(b => b.ID));Transactions
All-or-nothing operations. If any operation fails, all are rolled back.
// Successful transaction
const [book1, book2, book3] = await client.transaction(tx => [
tx.Books.create({ title: 'Transaction Book 1', author_ID: 101, genre_ID: 'fiction', price: 19.99 }),
tx.Books.create({ title: 'Transaction Book 2', author_ID: 101, genre_ID: 'fiction', price: 29.99 }),
tx.Books.create({ title: 'Transaction Book 3', author_ID: 101, genre_ID: 'fiction', price: 39.99 })
]);
// Failed transaction - all rolled back
try {
await client.transaction(tx => [
tx.Books.create({ title: 'Will be rolled back', author_ID: 101, genre_ID: 'fiction', price: 9.99 }),
tx.Books.create({ title: 'Missing required field' }) // Fails - missing genre_ID
]);
} catch (error) {
// Neither book was created
}Filtering
Type-safe filtering with operators - no more manual $filter strings.
Object Syntax
// Simple equality
filter: { Category: 'Electronics' }
// Multiple conditions (AND)
filter: { Category: 'Electronics', Active: true }
// Comparison operators
filter: { Price: { gt: 100 } } // Greater than
filter: { Price: { lt: 500 } } // Less than
filter: { Price: { ge: 100 } } // Greater than or equal
filter: { Price: { le: 500 } } // Less than or equal
filter: { Price: { ne: 0 } } // Not equal
filter: { Price: { gt: 100, lt: 500 } } // Range
// String operators
filter: { Name: { contains: 'Pro' } }
filter: { Name: { startswith: 'A' } }
filter: { Name: { endswith: 'ion' } }
// Array operators
filter: { Status: { in: ['active', 'pending'] } }
filter: { Price: { between: [100, 500] } }Logical Operators
// OR conditions
filter: {
Category: 'Electronics',
$or: [
{ Price: { lt: 100 } },
{ OnSale: true }
]
}
// NOT condition
filter: {
$not: { Discontinued: true }
}
// Complex nested logic
filter: {
$or: [
{ Category: 'Electronics', Price: { lt: 500 } },
{ Category: 'Books', Rating: { ge: 4 } }
]
}Raw OData Syntax
// When you need full control
filter: "Price gt 100 and contains(Name,'Pro')"Sorting
Multiple formats supported:
// String
orderBy: 'Name desc'
orderBy: 'Category asc, Name desc'
// Object
orderBy: { Name: 'desc' }
orderBy: { Category: 'asc', Name: 'desc' }
// Array (explicit order)
orderBy: [{ Category: 'asc' }, { Name: 'desc' }]Expanding Relations
Fetch related entities in a single request.
// Simple expand
expand: ['Category', 'Supplier']
expand: { Category: true, Supplier: true }
// Expand with nested options
expand: {
Items: {
select: ['ItemID', 'Quantity', 'Price'],
filter: { Quantity: { gt: 0 } },
orderBy: { ItemID: 'asc' },
top: 10
}
}
// Nested expand
expand: {
Items: {
expand: { Product: true }
}
}Pagination
Async Iterator
Process large datasets efficiently:
for await (const page of client.Products.paginate({ pageSize: 100 })) {
console.log(`Processing ${page.value.length} of ${page.count} total`);
for (const product of page.value) {
// Process each product
}
}
// With maximum items limit
for await (const page of client.Products.paginate({ pageSize: 100, maxItems: 500 })) {
process(page.value);
}Get All
Fetch all records with auto-pagination:
const allProducts = await client.Products.all();Manual Pagination
// First page
const page1 = await client.Products.listWithCount({ top: 20, skip: 0 });
// Next page
const page2 = await client.Products.listWithCount({ top: 20, skip: 20 });Navigation Properties
Access related entities directly:
// Using expand
const order = await client.Orders.get(12345, {
expand: { Items: true }
});
console.log(order.Items);
// Using nav() for direct access
const items = await client.Orders.nav(12345, 'Items').list();
// Chain operations
const expensiveItems = await client.Orders.nav(12345, 'Items').list({
filter: { Price: { gt: 100 } },
orderBy: { Price: 'desc' }
});Transactions
Atomic operations - all succeed or all fail.
const [order, items] = await client.transaction(tx => [
tx.Orders.create({
CustomerID: 'ALFKI',
OrderDate: new Date().toISOString()
}),
tx.OrderItems.createMany([
{ ProductID: 1, Quantity: 5 },
{ ProductID: 2, Quantity: 3 }
])
]);Supported operations in transactions:
create(data)update(id, data)delete(id)createMany(items)updateMany(items)deleteMany(ids)
Error Handling
Typed errors with helpful messages:
import {
S4KitError,
NotFoundError,
ValidationError,
AuthenticationError,
RateLimitError
} from 's4kit';
try {
await client.Products.get(99999);
} catch (error) {
if (error instanceof NotFoundError) {
console.log(error.message); // "Entity not found"
console.log(error.help); // "Verify the entity exists..."
console.log(error.status); // 404
}
if (error instanceof ValidationError) {
console.log(error.fieldErrors); // Map of field-level errors
console.log(error.getFieldError('Name')); // "Name is required"
}
if (error instanceof RateLimitError) {
console.log(error.retryAfter); // Seconds to wait
}
if (error instanceof S4KitError) {
console.log(error.code); // Error code
console.log(error.friendlyMessage);
console.log(error.toJSON()); // Serializable error info
}
}Error Types
| Error | Status | Description |
|-------|--------|-------------|
| NetworkError | - | Connection failed |
| TimeoutError | - | Request timed out |
| AuthenticationError | 401 | Invalid API key |
| AuthorizationError | 403 | Permission denied |
| NotFoundError | 404 | Entity not found |
| ValidationError | 400 | Invalid request data |
| ConflictError | 409 | Optimistic locking conflict |
| RateLimitError | 429 | Too many requests |
| ServerError | 5xx | Server error |
Interceptors
Hook into the request lifecycle:
const client = S4Kit({ apiKey: 'sk_live_...' })
.onRequest((config) => {
console.log('Request:', config.method, config.url);
return config;
})
.onResponse((response) => {
console.log('Response:', response.status);
return response;
})
.onError((error) => {
console.log('Error:', error.message);
throw error;
});OData Functions & Actions
Call unbound and bound operations:
// Unbound function (GET)
const result = await client.Entity.func('GetStatistics', {
year: 2024,
region: 'EU'
});
// Unbound action (POST)
const result = await client.Entity.action('ProcessBatch', {
items: [1, 2, 3]
});
// Bound function on entity instance
const discount = await client.Products.boundFunc(123, 'CalculateDiscount', {
quantity: 10
});
// Bound action on entity instance
await client.Orders.boundAction(456, 'Approve');Query Builder
Fluent API alternative:
import { query } from 's4kit';
const products = await query(client.Products)
.select('ProductID', 'Name', 'Price')
.where('Category', 'eq', 'Electronics')
.and('Price', 'gt', 100)
.orderBy('Price', 'desc')
.top(20)
.execute();
// With count
const { value, count } = await query(client.Products)
.where('Active', 'eq', true)
.count()
.executeWithCount();
// First or single
const first = await query(client.Products).where('ID', 'eq', 1).first();
const single = await query(client.Products).where('ID', 'eq', 1).single();Advanced
Composite Keys
// Simple key
await client.Products.get(123);
await client.Products.get('ABC');
// Composite key
await client.OrderItems.get({ OrderID: '12345', ItemNo: 10 });Per-Request Overrides
// Override connection
await client.Products.list({
connection: 'sandbox',
service: 'API_PRODUCT_SRV'
});
// Get raw OData response
const raw = await client.Products.list({
raw: true
});Instance Selection
When your API key has access to multiple instances (e.g., sandbox, dev, production) for the same service, the platform automatically selects the highest-level instance by default:
Priority order: production > preprod > quality > dev > sandbox
To explicitly target a specific instance, use custom headers:
const client = S4Kit({
apiKey: 'sk_live_...',
headers: {
'X-S4Kit-Instance': 'sandbox' // Force sandbox instance
}
});
// Or per-request:
await client.Products.list({
headers: {
'X-S4Kit-Instance': 'dev'
}
});Available headers:
| Header | Description |
|--------|-------------|
| X-S4Kit-Instance | Target instance environment (sandbox, dev, quality, preprod, production) |
| X-S4Kit-Service | Override service alias (optional, auto-resolved from entity name) |
Search
// Full-text search
const results = await client.Products.list({
search: 'laptop computer'
});Examples
See the examples directory for complete working examples:
demo.ts- Complete feature walkthrough
License
MIT
