notsapodata
v0.0.15
Published
OData utilities and client package
Readme
notsapodata
Overview
A truly type-safe TypeScript OData v2/v4 client with comprehensive IDE support. Probably the first OData client where IntelliSense guides you through entity sets, fields, and navigation properties — while TypeScript catches service misuse at compile time.
Features
- Full type safety: Entity sets, fields, navigation properties, and function parameters validated at compile time
- Wide IDE support: IntelliSense and autocomplete throughout the entire query building process
- Static analysis: Catches wrong service usage before runtime
- Vite plugin generates TypeScript types from OData metadata
- CSRF handling, batching, and deep navigation
- Filter builder with all OData operators
- Excel export and value help utilities
Works with SAP, HANA, Microsoft, and other OData services.
Installation
npm install notsapodata
# or
pnpm add notsapodata
yarn add notsapodataType Generation
Using the Vite Plugin
Import notSapODataVitePlugin from notsapodata/vite. The plugin fetches metadata and generates typed models to src/.odata.types.ts.
Minimal Example - Fetching Metadata from Service
import { defineConfig } from 'vite'
import notSapODataVitePlugin from 'notsapodata/vite'
export default defineConfig({
plugins: [
notSapODataVitePlugin({
services: {
NorthwindV4: {
host: 'https://services.odata.org',
path: '/V4/Northwind/Northwind.svc',
},
},
}),
],
})With Authentication and Entity Selection
import { defineConfig } from 'vite'
import notSapODataVitePlugin from 'notsapodata/vite'
const cookie = process.env.ODATA_COOKIE_NAME && process.env.ODATA_COOKIE_VALUE
? `${process.env.ODATA_COOKIE_NAME}=${process.env.ODATA_COOKIE_VALUE}`
: undefined
export default defineConfig({
plugins: [
notSapODataVitePlugin({
filename: 'src/.odata.types.ts',
services: {
sapV4: {
host: process.env.ODATA_HOST ?? 'https://my.sap.example',
path: '/sap/opu/odata4/sap/zsb_bp_data/srvd/sap/zsd_bp_data/0001',
alias: 'SapV4',
entitySets: [
'ZSD_BP_DATA_CDS.CustomerSet',
'ZSD_BP_DATA_CDS.Groups',
],
headers: cookie ? { cookie } : {},
},
},
}),
],
})Using Offline Metadata
import { defineConfig } from 'vite'
import notSapODataVitePlugin from 'notsapodata/vite'
import { readFileSync } from 'node:fs'
const metadataXml = readFileSync('./fixtures/northwind-v4.xml', 'utf-8')
export default defineConfig({
plugins: [
notSapODataVitePlugin({
services: {
MockNorthwind: {
metadata: metadataXml, // Provide XML directly, no network call
path: '/mock/Northwind',
alias: 'NorthwindMock',
},
},
}),
],
})Environment variables:
ODATA_HOST– default host whenservice.hostis omittedODATA_COOKIE_NAMEandODATA_COOKIE_VALUE– authentication credentials
Service Configuration Options
| Option | Type | Description |
| --- | --- | --- |
| host? | string | Base host for all requests. Required when metadata is fetched from the service. |
| path? | string | Service path (e.g. /sap/opu/odata/sap/MY_SERVICE). Used by generated models and metadata fetch. |
| headers? | Record<string,string> | Extra headers (cookies, language, etc.). |
| alias? | string | Overrides the generated class/interface base name. |
| entitySets? | string[] | Restrict generation to specific entity sets (fully-qualified names). Omit to include everything. |
| metadata? | string | Provide inline metadata XML (no network call). |
Programmatic Generation
import { generate } from 'notsapodata/codegen'
import { writeFileSync } from 'node:fs'
// Generate types without Vite
const content = await generate({
NorthwindV4: {
host: 'https://services.odata.org',
path: '/V4/Northwind/Northwind.svc',
},
})
writeFileSync('src/.odata.types.ts', content)Generated Output Structure
For a service named MyModel:
- Constants:
myModelConsts- Field, key, and measure arrays per entity type - Types Interface:
TMyModel- TypeScript types from constants - OData Interface:
TMyModelOData- Entity sets, types, and functions - Service Class:
MyModel- ExtendsOData<TMyModelOData>
// Constants with field/key/measure lists
export const myModelConsts = {
"Namespace": {
"EntityType": {
fields: ['Field1', 'Field2'] as const,
keys: ['Field1'] as const,
measures: [] as const,
}
}
}
// Service class with static helpers
export class MyModel extends OData<TMyModelOData> {
public static getInstance() { /* singleton */ }
public static async entitySet(name) { /* direct entity access */ }
}Simple OData Requests
Getting an Entity Set
import { NorthwindV4 } from '@/.odata.types'
// Get entity set using the static method (recommended)
const products = await NorthwindV4.entitySet('Products')
// Simple query with top 10 records
const result = await products.query({ top: 10 })
// GET: .../Products?$top=10Reading a Single Record
const products = await NorthwindV4.entitySet('Products')
// Read a single record by key
const product = await products.readRecord({ ProductID: 42 })
// GET: .../Products(ProductID=42)Complex Queries
All Query Parameters
const products = await NorthwindV4.entitySet('Products')
const orders = await NorthwindV4.entitySet('Orders')
// Example with all supported parameters
const result = await products.query({
top: 10,
skip: 20,
select: ['ProductID', 'ProductName', 'UnitPrice'],
filter: { UnitPrice: { gt: 50 } },
sorters: [{ name: 'ProductName', desc: false }],
expand: products.expand('Category'),
inlinecount: 'allpages', // V2: returns count
// count: true, // V4: use this instead
apply: 'filter(CategoryID eq 1)', // V4 only
})Filter Parameter
Filters support type-safe operators and logical combinations:
Basic Operators
// Equality and comparison
filter: {
ProductID: { eq: 42 }, // ProductID eq 42
UnitPrice: { gt: 50 }, // UnitPrice gt 50
UnitsInStock: { le: 100 }, // UnitsInStock le 100
}
// String operations
filter: {
ProductName: { contains: 'Chai' }, // contains(ProductName,'Chai')
ProductName: { starts: 'A' }, // startswith(ProductName,'A')
ProductName: { ends: 'tea' }, // endswith(ProductName,'tea')
}
// Range (between)
filter: {
UnitPrice: { bw: ['10', '50'] } // (UnitPrice ge 10 and UnitPrice le 50)
}
// Null/empty checks
filter: {
Discontinued: { empty: true }, // Discontinued eq null
ProductName: { notEmpty: true } // ProductName ne null
}Logical Operators
// AND (implicit with array or object)
filter: [
{ ProductName: { contains: 'Chai' } },
{ UnitPrice: { bw: ['10', '50'] } },
]
// Result: (contains(ProductName,'Chai') and (UnitPrice ge 10 and UnitPrice le 50))
// OR expression
filter: {
$or: [
{ ProductID: { eq: 1 } },
{ ProductID: { eq: 2 } },
{ ProductID: { eq: 3 } },
],
}
// Result: (ProductID eq 1 or ProductID eq 2 or ProductID eq 3)
// Complex nesting
filter: {
$or: [
{
$and: [
{ CategoryID: { eq: 1 } },
{ UnitPrice: { gt: 10 } }
]
},
{ Discontinued: { eq: true } }
]
}Select Parameter
// Select specific fields (type-safe)
select: ['ProductID', 'ProductName', 'UnitPrice']
// Result: $select=ProductID,ProductName,UnitPriceSorters Parameter
// Multiple sort criteria
sorters: [
{ name: 'UnitPrice', desc: true }, // Descending
'ProductName' // Ascending (shorthand)
]
// Result: $orderby=UnitPrice desc,ProductNameApply Parameter (V4 only)
// OData V4 aggregations and transformations
apply: 'filter(CategoryID eq 1)/aggregate(UnitPrice with sum as TotalPrice)'Type-Safe Expands
One of the key features is type-safe expand building with full IntelliSense support.
OData V4 Expands
V4 supports inline query options within expand:
const orders = await NorthwindV4.entitySet('Orders')
// Build type-safe expands with nested options
const customerExpand = orders.expand('Customer', {
select: ['CustomerID', 'CompanyName', 'Country']
})
const orderDetailsExpand = orders
.expand('Order_Details', {
filter: { Quantity: { gt: 5 } },
select: ['ProductID', 'Quantity', 'UnitPrice'],
top: 10
})
.expand('Product', { // Nested expand
select: ['ProductName', 'CategoryID']
})
.expand('Category') // Further nesting
const result = await orders.query({
top: 2,
expand: [customerExpand, orderDetailsExpand]
})
// Result: $expand=Customer($select=CustomerID,CompanyName,Country),Order_Details($filter=Quantity gt 5;$select=ProductID,Quantity,UnitPrice;$top=10;$expand=Product($select=ProductName,CategoryID;$expand=Category))OData V2 Expands
V2 uses simpler path-based expansion. Filters and selects are applied at the root level with navigation prefixes:
// V2: Simple expansion paths
expand: 'Order_Details/Product'
// V2: Filters use navigation prefix at root
filter: {
'Order_Details/Quantity': { gt: 10 }, // Applied at root with path prefix
}
select: ['OrderID', 'Order_Details/ProductID', 'Order_Details/Product/ProductName']Working with Large Result Sets
const model = NorthwindV4.getInstance()
const progress: number[] = []
const query = model.readAllEntries('Products', {
chunkSize: 500,
progressCb: (loaded, total, done) => {
progress.push(loaded)
if (done) {
console.log(`Loaded ${loaded} of ${total}`)
}
},
})
const rows = await query.promise
// Abort if needed: query.abort()readAllEntries performs chunked reads (defaults to 100 records) and reuses the batching engine under the hood.
Navigation
Navigation allows you to traverse relationships between entities:
const orders = await NorthwindV4.entitySet('Orders')
// Single-level navigation: Orders -> Customer
const customer = await orders
.withKey({ OrderID: 10248 })
.toOne('Customer')
.read()
// GET: .../Orders(OrderID=10248)/Customer
// Multi-level navigation: Orders -> Customer -> Orders
const customerOrders = orders
.withKey({ OrderID: 10248 })
.toOne('Customer')
.toMany('Orders')
const result = await customerOrders.query({ top: 5 })
// GET: .../Orders(OrderID=10248)/Customer/Orders?$top=5
// Complex 3+ level navigation
const deepNav = orders
.withKey({ OrderID: 10249 })
.toMany('Order_Details')
.withKey({ OrderID: 10249, ProductID: 11 })
.toOne('Product')
.toOne('Category')
const category = await deepNav.read()
// GET: .../Orders(OrderID=10249)/Order_Details(OrderID=10249,ProductID=11)/Product/CategoryHow Navigation Works
withKey()- Specifies which record to start fromtoOne()- Navigate to a single related entity (1:1 or N:1)toMany()- Navigate to a collection of related entities (1:N)- Chain methods to build deep navigation paths
- End with
.query()or.read()to execute the request
Calling Function Imports
const model = NorthwindV4.getInstance()
await model.callFunction('SapService.CalculateTotals', {
FiscalYear: '2024',
})Batch Execution
There are two batching modes:
- Implicit queueing – set
model.options.useBatch = trueand the client will coalesce consecutive calls automatically. - Manual control – create a dedicated batch instance.
const model = NorthwindV4.getInstance()
const batch = model.createBatch()
const productsPromise = batch.read('Products', { $top: 10 })
const ordersPromise = batch.read('Orders', { $top: 10 })
const { promise } = batch.execute({ maxBatchLength: 10, maxConcurrentBatches: 2 })
await promise
const products = await productsPromise
const orders = await ordersPromiseMetadata Helpers
Accessing Metadata
// If you need metadata for advanced operations
const model = NorthwindV4.getInstance()
const metadata = await model.getMetadata()
const productType = metadata.getEntityType('NorthwindModel.Product')Refining Metadata
const products = await NorthwindV4.entitySet('Products')
products.refineField('ProductName', {
$label: 'Product',
$MaxLength: 100,
})Excel Export
const products = await NorthwindV4.entitySet('Products')
const { data } = await products.query({
select: ['ProductID', 'ProductName', 'UnitPrice', 'CategoryID'],
})
const buffer = await products.generateExcel(
['ProductID', 'ProductName', 'UnitPrice', 'CategoryID'],
data,
{
subtotals: [
{
grpBy: ['CategoryID'],
aggregate: ['UnitPrice'],
},
],
}
)
// buffer is a Node.js Buffer (Excel workbook)Errors and Fetch
ifetch wraps the Fetch API and raises rich error objects:
SapODataError– thrown when the response body matches the SAP error schema.IFetchError– thrown for network errors or non-OData error responses.
import { IFetchError, SapODataError } from 'notsapodata'
try {
await model.updateRecordByKey('Products(1)', { ProductName: 'Test' })
} catch (error) {
if (error instanceof SapODataError) {
console.error(error.code, error.message)
} else if (error instanceof IFetchError) {
console.error(error.status, error.statusText)
}
}Best Practices
- Use
Service.entitySet()for quick access without fetching metadata first - Use structured filters over manual OData strings:
{ Field: { gt: '10' } } - Cache entity sets—they store field maps and annotations
- Chain
toOne()andtoMany()for deep navigation - Group batch calls logically to stay under 100 requests per batch
- Catch
SapODataErrorseparately to show SAP diagnostics
