@urartian/loopback4-odata
v1.0.1
Published
LoopBack 4 extension that adds OData protocol support with decorators, generated CRUD routes, and PostgreSQL-focused production guidance.
Downloads
247
Maintainers
Readme
@urartian/loopback4-odata
An extension for LoopBack 4 that adds OData protocol support. This package implements OData v4.0 for LoopBack 4 applications.
- Auto-discovers OData controllers and generates CRUD routes.
- Exposes OData-style endpoints (
/Products(1)) and OpenAPI-compliant ones (/Products/{id}). - Provides
$metadataendpoint. - Simple developer experience with decorators.
- Advanced
$applysupport including chained transformations, navigation-path aggregates, and safe in-memory fallbacks when connector pushdown is unavailable. - Streams
$batchmultipart payloads end-to-end, preserving binary sub-responses (PDFs, CSV exports, etc.) without re-encoding them (JSON batches base64-encode binary bodies and include theirContent-Type).
For v1, the officially documented and supported SQL path is PostgreSQL. Other connector-specific paths that may still exist in the codebase are not part of the supported surface yet.
v1.0.0 is the first supported public release of the package. CRUD endpoints, $expand, $count, $batch, Actions/Functions, server-driven paging ($skiptoken), delta links ($deltatoken), and PostgreSQL-first production guidance are part of the documented surface. Ongoing work is tracked in docs/roadmap.md.
Community and support guidance lives in docs/community.md.
Installation
npm install @urartian/loopback4-odata
# if your application does not yet depend on LoopBack core packages:
npm install @loopback/core@^7 @loopback/repository@^8 @loopback/rest@^15 @loopback/boot@^8Requires Node.js 22.x and the host application must supply compatible versions of
@loopback/boot,@loopback/core,@loopback/repository, and@loopback/rest(the extension lists them as peer dependencies to avoid duplicate copies).
Getting Started
- Enable the component
In your application class:
import { ApplicationConfig } from '@loopback/core';
import { BootMixin } from '@loopback/boot';
import { RepositoryMixin } from '@loopback/repository';
import { RestApplication } from '@loopback/rest';
import { ODataComponent, ODATA_BINDINGS, ODataConfig } from '@urartian/loopback4-odata';
export class MyAppApplication extends BootMixin(RepositoryMixin(RestApplication)) {
constructor(options: ApplicationConfig = {}) {
super(options);
this.component(ODataComponent); // enable OData support
const current = this.getSync(ODATA_BINDINGS.CONFIG) as ODataConfig;
this.bind(ODATA_BINDINGS.CONFIG).to({
...current,
tokenSecret: process.env.ODATA_TOKEN_SECRET ?? current.tokenSecret,
});
// Register datasources and repositories once they are defined (see Step 3).
}
}Production tip: In production (
NODE_ENV=productionorODATA_ENV=production) you must provideODATA_TOKEN_SECRET; the component fails to boot when it’s missing. In non-production environments the component auto-generates a random per-boot secret when none is configured and logs an INFO message (Generated per-boot OData token secret ...). SetODATA_TOKEN_SECRET=$(openssl rand -hex 32)locally if you need tokens to survive restarts. Rotating the secret (manually or via auto-generation) invalidates existing$skiptoken/$deltatokenlinks.
- Define a model
import { Entity, property } from '@loopback/repository';
import { odataModel } from '@urartian/loopback4-odata';
@odataModel({
lbModel: { settings: { strict: true } },
etag: 'updatedAt',
delta: {
enabled: true,
field: 'updatedAt',
},
})
export class Product extends Entity {
@property({ id: true })
id!: number;
@property()
name!: string;
@property()
price!: number;
@property({ type: 'date', defaultFn: 'now' })
updatedAt!: Date;
}Note: @odataModel() replaces LoopBack’s @model() for OData entity sets, but you still use standard LoopBack @property/relation decorators to define schema and relations. The lbModel block is passed through 1:1 to LoopBack’s @model(definition) decorator (the same object you’d otherwise pass to @model({ ... })). See LoopBack model definition settings: https://loopback.io/doc/en/lb4/Model.html#supported-entries-of-model-definition
Common lbModel.settings examples:
@odataModel({ lbModel: { settings: { strict: true } } })
@odataModel({ lbModel: { settings: { postgresql: { table: 'products' } } } })
@odataModel({ lbModel: { settings: { indexes: { idx_sku: { keys: { sku: 1 }, options: { unique: true } } } } } })- Create a repository
The component expects a DefaultCrudRepository binding for each model decorated with @odataController. Provide a datasource and expose the repository via RepositoryMixin.
import { inject } from '@loopback/core';
import { DefaultCrudRepository, juggler } from '@loopback/repository';
const ds = new juggler.DataSource({
name: 'db',
connector: 'memory',
});
export class ProductRepository extends DefaultCrudRepository<Product, typeof Product.prototype.id> {
constructor(@inject('datasources.db') dataSource: juggler.DataSource) {
super(Product, dataSource);
}
}Register both the datasource and the repository inside the application constructor from Step 1:
this.dataSource(ds);
this.repository(ProductRepository);- Add a controller
@odataController(Product)
export class ProductODataController {}That’s it — the extension generates repository-backed CRUD endpoints automatically.
Singletons
OData V4 singletons expose a single entity instance at a stable URL (for example /odata/Me)
while reusing the same model/repository and CSDL EntityType as the backing entity set.
Entity sets vs singletons (important)
By default, declaring a singleton does not disable the entity set endpoints for that model.
When you decorate a model with both entitySetName and singleton, this extension exposes two
top-level resources:
- EntitySet at
/odata/<entitySetName>(collection semantics)GET /odata/<entitySetName>returns{ "@odata.context": "...", "value": [ ... ] }POST /odata/<entitySetName>creates a new entityGET/PATCH/PUT/DELETE /odata/<entitySetName>(<key>)address a single entity by key
- Singleton at
/odata/<singleton.name>(single-entity semantics)GET /odata/<singleton.name>returns a single entity (novaluearray)PATCH/PUT /odata/<singleton.name>update/replace the singleton entityDELETE /odata/<singleton.name>is only allowed whensingleton.nullable: true
To avoid route and metadata conflicts, singleton names must not match the entity set name
(case-insensitive) unless you enable singletonOnly (see below).
Declare a singleton on the model via @odataModel({ singleton: ... }):
@odataModel({
entitySetName: 'Users',
singleton: {
name: 'Me',
resolveId: async ({ request }) => (request as any).user.id,
},
})
export class User extends Entity {}Static singleton (fixed key):
@odataModel({
// Pick distinct names: the entity set is a collection, the singleton is a single entity.
entitySetName: 'SettingEntries',
singleton: { name: 'Settings', id: '1' },
})
export class AppSettings extends Entity {}Interop note
Some OData servers choose to treat singletons as “singleton-only” resources
that reject POST and do not expose a corresponding entity set collection endpoint at the same
URL. By default, this extension exposes singletons in addition to the entity set endpoints, so
clients must call the singleton URL (/odata/<singleton.name>) to get singleton semantics. Use
singletonOnly: true for singleton-only behavior.
Singleton-only mode (singletonOnly: true)
To prevent accidental use of the entity set endpoints (for example, POST /odata/Settings
creating multiple rows), you can enable singleton-only mode:
@odataModel({
entitySetName: 'Settings',
singletonOnly: true,
singleton: { name: 'Settings', id: '1' },
})
export class AppSettings extends Entity {}When singletonOnly: true:
- The service document and
$metadataomit theEntitySetentry for the model. - Collection/entity set CRUD routes are not registered (no
GET/POST /odata/<EntitySetName>). - The singleton routes remain available under
/odata/<singleton.name>. singleton.namemay matchentitySetName(for example/odata/Settings).- Bound actions/functions and entity-set navigation routes are not generated in this mode yet.
Endpoints (examples):
GET /odata/MePATCH /odata/MeandPUT /odata/Me- Navigation reads like
GET /odata/Me/Orders $refroutes likePOST /odata/Me/Orders/$ref,PUT /odata/Me/Manager/$ref, andDELETE /odata/Me/Orders(<key>)/$ref(relation-dependent)
The service document (GET /odata) and $metadata include singleton entries. DELETE /odata/<Singleton>
is only allowed when singleton.nullable: true.
Errors and error codes
OData endpoints return errors using an OData error payload (for example {"error":{"code":"BadRequest","message":"..."}}).
error.codeis stable and intended for client logic (use it instead of parsing messages).error.messageis human-readable and may change; do not parse it for behavior.- If a downstream connector/database error bubbles up with its own string
err.code, it is preserved as diagnostics undererror.innererror.dbCode(not aserror.code).
The authoritative list of stable codes lives in src/odata-error-codes.ts (exported as ODataErrorCodes from @urartian/loopback4-odata).
Stable codes are grouped as follows:
- Core HTTP-derived codes:
BadRequest,Unauthorized,Forbidden,NotFound,Conflict,MethodNotAllowed,PreconditionFailed,PreconditionRequired,PayloadTooLarge,NotImplemented,InternalServerError,NotAcceptable,UnsupportedMediaType,UnprocessableEntity,Gone,TooManyRequests,ServiceUnavailable. These are the default response codes emitted when no more specific OData code is attached to the error. - Batch and transport codes:
InvalidUrl,InvalidMethod,ResponseTooLarge,TooManyRedirects,BatchExecutionError,BatchSubRequestTimeout,batch-operation-limit-exceeded,changeset-operation-limit-exceeded,batch-payload-size-limit-exceeded,batch-part-size-limit-exceeded,batch-depth-limit-exceeded. These identify malformed batch requests, batch guardrail violations, redirect problems, timeouts, and oversized responses. - Preferences, tenancy, and transaction codes:
PreferenceNotSupported,TenantResolutionFailed,TransactionCommitFailed,TransactionsNotSupported,MultiDataSourceChangesetNotSupported,AtomicityGroupNotSupported. These cover unsupported client preferences, tenant resolution failures, and transactional guarantees that cannot be honored. - Content-ID resolution code:
content-id-reference-invalid. This is returned when a$batchrequest references an unknown or invalidContent-ID. - Query, lambda, pushdown, and guardrail codes:
lambda-or-unsupported,nested-lambda-depth-exceeded,lambda-alias-prefix-required,pushdown-join-count-exceeded,through-relation-unsupported,navigation-filter-requires-pushdown,postfilter-requires-pushdown,postfilter-top-required,postfilter-scan-limit-exceeded,lambda-pushdown-not-eligible,lambda-scan-limit-exceeded. These describe unsupported query shapes, lambda validation failures, and cases where bounded in-memory fallback or SQL pushdown constraints were exceeded. - Typed literal validation codes:
invalid-guid-literal,invalid-date-literal,invalid-datetimeoffset-literal,invalid-int64-literal,invalid-decimal-literal,in-list-too-large,in-operator-requires-list,in-operator-requires-literal-list-items,in-operator-requires-non-empty-list. These are used when OData literals cannot be parsed safely or whenin (...)operands violate validation rules.
For client code, prefer importing ODataErrorCodes and comparing against the exported constants instead of hard-coding string literals.
Authentication & Authorization
The component mirrors LoopBack’s authentication and authorization metadata from your controller onto every generated CRUD endpoint. Decorate your OData controller exactly as you would a regular REST controller and the extension takes care of the rest:
import { authenticate } from '@loopback/authentication';
import { authorize } from '@loopback/authorization';
@odataController(Product)
@authenticate('jwt')
@authorize({ scopes: ['product.read'] })
export class ProductODataController {
// Stubbing a method is enough to apply fine-grained metadata.
// Decorators (authorization, interceptors, etc.) only run when a stub exists.
@authorize({ scopes: ['product.summary'] })
async find() {}
}Generated routes (list, findById, create, update, delete) will enforce the same strategies and scopes. $batch requests automatically reuse the caller’s headers and resolved user profile, so you don’t have to repeat credentials for each entry.
LoopBack’s built-in methods (find, findById, create, updateById, replaceById, deleteById, count) are remapped to the OData CRUD handlers automatically. $value routes reuse metadata from getMediaValue, replaceMediaValue, and deleteMediaValue, so add empty stubs with those names when you need to secure media streams:
@odataController(MediaAsset)
@authenticate('jwt')
export class MediaAssetController {
@authorize({ scopes: ['media.read'] })
async find() {}
@authorize({ scopes: ['media.read'] })
async findById() {}
@authorize({ scopes: ['media.read'] })
async getMediaValue() {}
@authorize({ scopes: ['media.update'] })
async replaceMediaValue() {}
@authorize({ scopes: ['media.delete'] })
async deleteMediaValue() {}
}If you expose differently named controller methods, supply custom aliases when registering the entity set so the security metadata still flows through. Any of the canonical handlers below can be remapped: find, findById, create, updateById, replaceById, deleteById, count, linkNavigationRef, unlinkNavigationRef, getMediaValue, replaceMediaValue, and deleteMediaValue.
import { ODATA_BINDINGS } from '@urartian/loopback4-odata';
const registry = await app.get(ODATA_BINDINGS.ENTITY_SET_REGISTRY);
registry.register({
name: 'Products',
modelCtor: Product,
repositoryBindingKey: 'repositories.ProductRepository',
securityMethodAliases: {
find: 'list',
findById: 'get',
create: 'create',
updateById: 'patch',
replaceById: 'put',
deleteById: 'remove',
count: 'count',
linkNavigationRef: 'attachRelation',
unlinkNavigationRef: 'detachRelation',
replaceMediaValue: 'uploadBinary',
getMediaValue: 'downloadBinary',
deleteMediaValue: 'removeBinary',
},
});Write metadata from the controller (update*, replace*, patch*, delete*) automatically propagates to the navigation $ref handlers so users with read-only scopes cannot relink entities. If you need different policies on $ref, you can still override them via securityMethodAliases or by decorating the stub methods directly:
@odataController(Order)
@authenticate('jwt')
export class OrderODataController {
// Override default write policy by attaching custom metadata
@authorize({ allowedRoles: ['order-manager'] })
async linkNavigationRef() {}
@authorize({ allowedRoles: ['order-manager'] })
async unlinkNavigationRef() {}
}Navigation reference routes run through the generated CRUD controller, so the same LoopBack authentication and authorization interceptors execute before links are created or removed. Stub methods with @authenticate / @authorize metadata (or aliases from the writable methods) are enough to secure the $ref endpoints without any additional plumbing.
Endpoints (Phase 3)
Start your app and test:
npm startFor a quick demo, run npm run dev; this boots the example app in examples/basic-app, with an in-memory datasource pre-seeded with sample products and orders so you can experiment with the query options immediately.
The example binds
tokenSecretfromprocess.env.ODATA_TOKEN_SECRETbut the component already enforces the same behavior internally. In production you must set that environment variable; in dev/test a random secret is generated per boot (logged at INFO) unless you override it. You can also tweak guardrails at runtime via environment variables such asBATCH_MAX_OPERATIONS,BATCH_MAX_PART_BYTES,ODATA_MAX_TOP,ODATA_MAX_SKIP,ODATA_MAX_PAGE_SIZE, andODATA_MAX_APPLY_PAGE_SIZE.
Metadata
GET /odata/$metadataReturns generated EDMX/CSDL describing your registered entity sets.
Collection
GET /odata/ProductsResponse:
{
"@odata.context": "/odata/$metadata#Products",
"value": [
{
"id": 1,
"name": "Laptop",
"price": 1299
}
]
}Single entity
Both forms work:
GET /odata/Products/1
GET /odata/Products(1)Response:
{
"@odata.context": "/odata/$metadata#Products/$entity",
"@odata.etag": "W/\"01FZJH6G9E7AM4\"",
"id": 1,
"name": "Laptop",
"price": 1299
}Primitive values
Use the $value path segment to stream a single primitive property:
GET /odata/Products(1)/name/$valueResponse (text/plain):
LaptopFor binary fields the server responds with application/octet-stream and streams the raw payload.
Create
curl -X POST /odata/Products \
-H 'Content-Type: application/json' \
-d '{"name":"Laptop","price":1299}'Returns the persisted entity at the top level with standard OData annotations.
Update & Delete
PATCH /odata/Products/1
DELETE /odata/Products/1PATCH accepts partial payloads, and DELETE responds with 204 No Content once the repository removes the entity.
Note
The CRUD controller does not generate a
PUT /odata/<EntitySet>/{id}endpoint. All scalar/JSON updates must go throughPATCH(or the$batchequivalent).PUTis reserved exclusively for$valuemedia streams, so callingPUT /odata/Products(1)/$valueonly replaces the binary payload and its metadata — it will never touch regular model properties liketitle.
When you enable optimistic concurrency by configuring an ETag property (for example @odataModel({etag: 'updatedAt'})), the generated endpoints require clients to supply the latest ETag via the If-Match request header. Missing headers result in 428 Precondition Required, while mismatched values return 412 Precondition Failed. ETags are exposed both in response headers and as the @odata.etag field in response bodies so clients can round-trip them easily.
Query options
Common OData query options are translated into LoopBack filters out of the box:
GET /odata/Products?$filter=price gt 500 and name ne 'Monitor'&$orderby=price desc&$top=5&$skip=10&$select=id,name,priceBecomes:
{
"where": {
"and": [{ "price": { "gt": 500 } }, { "name": { "neq": "Monitor" } }]
},
"order": ["price DESC"],
"limit": 5,
"offset": 10,
"fields": { "id": true, "name": true, "price": true }
}You can combine $filter (eq, ne, gt, ge, lt, le with and/or, plus string predicates like contains, startswith, endswith), $orderby, $top, $skip, and $select to shape the data returned by your repository queries.
Examples of string predicates translated to LoopBack filters:
GET /odata/Products?$filter=contains(name,'Lap'){
"where": {
"name": { "like": "%Lap%", "escape": "\\" }
}
}GET /odata/Products?$filter=startswith(code,'PR-'){
"where": {
"code": { "like": "PR-%", "escape": "\\" }
}
}GET /odata/Products?$filter=endswith(category,'ware'){
"where": {
"category": { "like": "%ware", "escape": "\\" }
}
}Use $expand to inline related models that are registered on your LoopBack entity relations:
GET /odata/Orders?$expand=customer,itemsThe extension validates relation names against the model metadata and produces the corresponding include filter:
{
"include": [{ "relation": "customer" }, { "relation": "items" }]
}$expand is also supported on single-entity requests (/odata/Orders(1)?$expand=customer). Unknown relation names result in a 400 Bad Request response so clients get immediate feedback when requesting unsupported navigation properties.
Note: OData identifiers are case-sensitive. Use the exact navigation property names exposed in
$metadata(for example,$expand=ordersnot$expand=Orders).
Use $levels to follow a recursive navigation property for multiple hops while reusing the same scoped options at every depth:
GET /odata/Employees?$expand=manager($levels=2;$select=id,name)This returns each employee with their direct manager and that manager's manager in a single round trip.
The $compute option projects virtual fields evaluated after the repository fetch. Expressions support arithmetic (add, sub, mul, div, mod), simple string helpers (tolower, toupper, concat), literals, and property paths:
GET /odata/OrderItems?$compute=quantity mul unitPrice as LineTotal&$select=id,LineTotalThe response includes the additional LineTotal column without altering/store schemas. Computed aliases can participate in $select and client-side sorting but are currently incompatible with $apply pushdowns or server-driven ordering.
Responses are emitted as JSON by default. Clients can force a JSON payload regardless of the Accept header via ?$format=json. Other media types (XML, CSV, etc.) are not yet supported.
Advanced filter helpers supported:
- Logical NOT
GET /odata/Products?$filter=not price gt 100{ "where": { "price": { "lte": 100 } } }- Numeric functions:
round,floor,ceiling
GET /odata/Products?$filter=round(price) eq 10{ "where": { "and": [{ "price": { "gte": 9.5 } }, { "price": { "lt": 10.5 } }] } }- Compound keys and alternate key predicates are rewritten transparently:
GET /odata/Orders(OrderID=10248,CustomerID='ALFKI')Normalizes to the REST-friendly route /odata/Orders/OrderID%3D10248%2CCustomerID%3DALFKI before reaching the controller, while preserving string literals (including embedded parentheses, commas, and escaped quotes).
- Date extraction:
year(<DateTimeOffset>) eq <year>
GET /odata/Orders?$filter=year(updatedAt) eq 2024Translates to a UTC date range for that year.
- Basic
$search
GET /odata/Products?$search=LaptopPerforms a case‑insensitive substring search across all string properties of the model. Multiple terms are OR’ed. Quoted phrases are treated as a single token. Boolean operators are not yet interpreted.
- String position:
indexof
GET /odata/Products?$filter=indexof(name,'Lap') ge 0Equivalent to contains(name,'Lap'). To test absence use eq -1, or wrap a supported comparison in not:
GET /odata/Products?$filter=indexof(name,'Lap') eq -1Strict limitations: only presence/absence forms are supported (ge 0, gt -1, eq -1) plus their negations. Exact position comparisons like indexof(name,'Lap') eq 2 are rejected with 400 in strict mode.
- Substring at position:
substring
GET /odata/Products?$filter=substring(code,2) eq 'ABC'Checks that code has ABC starting at index 2 (0‑based). With explicit length:
GET /odata/Products?$filter=substring(code,4,3) ne 'XYZ'Strict limitations: supports only eq / ne with a string literal on the right‑hand side. Other comparators or non‑string RHS are rejected (400).
- String length predicates:
length
GET /odata/Products?$filter=length(description) eq 0
GET /odata/Products?$filter=length(code) gt 3
GET /odata/Products?$filter=not length(code) lt 2All comparison operators (eq, ne, gt, ge, lt, le) are supported with integer literals.
- Lambda filters alongside additional predicates
GET /odata/Products?$filter=orderItems/any(i: i/unitPrice gt 800) and price gt 1000The parser keeps lambdas for post-processing or SQL pushdown while still applying any root predicates (like price gt 1000) to the database query. Lambdas support any/all across multi-segment navigation paths and can participate in full $filter boolean expression trees (for example (<lambda>) or (<root predicate>)). See Configuration for the full lambda grammar, guardrails, and pushdown options.
Searchable Fields
Control which fields participate in $search:
- Decorate properties with
@odataSearchable()in your model. - Or configure per–entity set in
ODataConfig.searchFields. - Default :
$searchis opt-in and uses only annotated fields. If no searchable fields are configured, strict mode returns400 Bad Request. - Guardrails: cap inputs with
maxSearchTerms(requests above the cap return400 Bad Request) and trim the evaluated field list withmaxSearchFieldsso only the first N configured properties participate.
Example:
@odataModel()
export class Product extends Entity {
@property({ id: true }) id!: number;
@odataSearchable() @property() name!: string;
@odataSearchable() @property() sku!: string;
@property() price!: number;
}
// Or centrally via config
this.bind(ODATA_BINDINGS.CONFIG).to({
searchMode: 'config-only',
searchFields: { Products: ['name', 'sku'] },
} as ODataConfig);Handling Large Integer Types (BigInt/Int64)
When working with large integer types like bigint in PostgreSQL or int64 in OData, special care must be taken to avoid JavaScript's number precision limitations. JavaScript's Number.MAX_SAFE_INTEGER is 9007199254740991, which is smaller than the maximum value for 64-bit integers (9223372036854775807).
To properly handle large integers without precision loss:
- Define the property as a string type with proper format specification:
@property({
type: 'string', // Use string type to preserve precision
jsonSchema: {
type: 'string',
format: 'int64',
dataType: 'int64'
},
postgresql: {
dataType: 'bigint' // Still maps to bigint in the database
}
})
sequence?: string; // Use string type in the application layer- Avoid using
type: 'number'for properties that might exceedNumber.MAX_SAFE_INTEGER:
❌ Incorrect:
@property({
type: 'number', // This can cause precision loss for large values
postgresql: {
dataType: 'bigint'
}
})
sequence?: number;✅ Correct:
@property({
type: 'string', // Preserves precision
jsonSchema: {
type: 'string',
format: 'int64' // Tells OData to treat as int64
},
postgresql: {
dataType: 'bigint' // Maps to bigint in database
}
})
sequence?: string;This approach ensures that large integer values maintain their precision throughout the application layer while still being stored as the appropriate database type. The OData extension will properly handle int64'9223372036854775807' literals without precision loss when the property is defined as a string with the proper format specification.
Key normalization
Incoming URLs are normalized by a path-rewriter middleware so /odata/Products(42) becomes /odata/Products/42 before routing. Key expressions are parsed strictly, escaped (including quotes and GUID prefixes), and capped at 4 KB; malformed or oversized segments are left untouched, which means the request proceeds with the original path and the framework responds with the usual 404/400.
Enable inline counts by passing $count=true alongside other query options:
GET /odata/Products?$filter=price gt 500&$count=trueResponse:
{
"@odata.context": "/odata/$metadata#Products",
"@odata.count": 12,
"value": [{ "id": 1, "name": "Laptop", "price": 1299 }]
}To fetch the count only, call the dedicated path:
GET /odata/Products/$countThe endpoint responds with a plain number and honours $filter (and other supported query options) to scope the count.
Batch multiple operations with a single round-trip using the $batch endpoint:
POST /odata/$batch
Content-Type: application/json
{
"requests": [
{"id": "1", "method": "GET", "url": "/odata/Products?$top=1"},
{"id": "2", "method": "GET", "url": "/odata/Products/$count"},
{
"id": "3",
"atomicityGroup": "changeset-1",
"method": "POST",
"url": "/odata/Products",
"body": {"name": "Tablet", "price": 499}
}
]
}Responses preserve request order; operations that share atomicityGroup succeed or fail together:
{
"responses": [
{ "id": "1", "status": 200, "body": { "value": [{ "id": 1, "name": "Laptop" }] } },
{ "id": "2", "status": 200, "body": { "value": [] } },
{ "atomicityGroup": "changeset-1", "id": "3", "status": 201, "body": { "value": { "id": 4 } } }
]
}Actions & Functions
You can publish custom OData operations on top of the generated CRUD surface by decorating controller methods. The OData booter discovers them at startup, wires REST routes automatically, and emits <Action> / <Function> entries in $metadata so OData clients can discover them.
@odataAction() and @odataFunction() accept the same options:
nameoverrides the exported operation name (defaults to the method name).bindingselects the scope:entity(default),collection, orunbound.paramsdescribes parameters for$metadata(each entry hasnameand optionaltype).returnTypesets the CSDL return type hint. It can be an EDM string ('Edm.Guid','Collection(Edm.String)', etc.), a LoopBack model constructor/factory (DecisionPermissionConfigor() => DecisionPermissionConfig), orcollectionOf(...)forCollection(...)return types. Functions default toEdm.Stringwhen omitted.rawResponseskips the default OData annotations (like@odata.context/@odata.etag) so you can return a bespoke payload.
params[].type and returnType accept either a literal EDM string ('Edm.Guid', 'Collection(Edm.String)', etc.) or a LoopBack model constructor (pass the class or a factory such as () => DecisionInput). When you reference a model, the generator emits the referenced complex type in $metadata, so clients can discover the schema of your payload/return type without adding dummy properties to entities. Runtime requests are validated against the model definition, so unknown properties trigger 400 Bad Request before your controller runs.
At runtime the framework resolves method arguments this way:
- Entity-bound operations receive the entity key as the first argument and then the JSON body (actions) or query object (functions).
- Collection-bound operations receive only the body/query object.
- Unbound operations are mounted at
/odata/<OperationName>and never receive an entity id.
| Binding | HTTP verb | Route example | Notes |
| ------------ | --------- | ------------------------------------- | ------------------------------------------------------------ |
| entity | POST/GET | POST /odata/Products(1)/discount | Actions expect a JSON body; functions read the query string. |
| collection | POST/GET | GET /odata/Products/premiumProducts | Operates on the entire set. |
| unbound | POST/GET | POST /odata/resetInventory | No entity segment; useful for cross-cutting jobs. |
Decorated methods still run through the standard LoopBack interceptors and middleware pipeline. The generated CSDL includes bound parameters and return types so metadata-driven tooling (e.g. Power BI, SAP UI5) can discover the operations automatically.
Example: entity action & collection function
Define custom actions and functions with decorators:
@odataController(Product)
class ProductController {
constructor(@repository(ProductRepository) private products: ProductRepository) {}
@odataAction({ binding: 'entity' })
async discount(id: number, body: { percent: number }) {
const entity = await this.products.findById(id);
const percent = Number(body?.percent ?? 0);
await this.products.updateById(id, {
price: Number(entity.price ?? 0) * (1 - percent / 100),
});
return this.products.findById(id);
}
@odataFunction({ binding: 'collection' })
async premiumProducts(query: { minPrice?: string }) {
const minPrice = Number(query?.minPrice ?? 1_000);
return this.products.find({ where: { price: { gte: minPrice } } });
}
}- Actions map to
POST /odata/Products({id})/discount(body contains parameters) and registered routes respect the usual LoopBack interceptors/middleware. - Functions map to
GET /odata/Products/premiumProducts?minPrice=1000and return a collection via GET. Canonical OData invocation is also accepted:GET /odata/Products/premiumProducts(minPrice=1000)(and the namespace-qualified formGET /odata/Products/Default.premiumProducts(minPrice=1000)). - Set
rawResponse: truein the decorator if you want to return a custom payload instead of the standard OData-formatted entity. - Decorated operations are listed automatically in
$metadata(CSDL) as bound/unbound actions and functions.
Complex return types
Use a model constructor/factory to generate ComplexType metadata for operation return values. For collections, wrap the type with collectionOf(...) (or use a literal Collection(...) CSDL string):
import { odataFunction, collectionOf } from '@urartian/loopback4-odata';
import { DecisionPermissionConfig } from '../models/decision-permission-config.model';
@odataFunction({
name: 'readDecisionPermissionConfig',
binding: 'unbound',
returnType: () => DecisionPermissionConfig,
})
async readDecisionPermissionConfig() {
return { id: '1', name: 'example' };
}
@odataFunction({
name: 'listDecisionPermissionConfigs',
binding: 'unbound',
returnType: collectionOf(() => DecisionPermissionConfig),
})
async listDecisionPermissionConfigs() {
return [{ id: '1', name: 'example' }];
}Controller Hooks & Overrides
Declare OData hooks right inside your LB4 controller using @odata.before, @odata.after, and @odata.on. These attach to the generated CRUD routes for your model and let you implement before/after logic or fully override an operation.
Import from the package root:
import { odata, CrudHookContext, CrudOnContext } from '@urartian/loopback4-odata';Supported operations and scopes:
- Operations:
READ,CREATE,UPDATE,DELETE,LINK_NAVIGATION,UNLINK_NAVIGATION - Scopes for
READ:collection,entity,count
Decorators accept a single operation, an array of operations, or '*' to run on every CRUD action. Arrays behave exactly like stacking individual decorators, so @odata.before(['CREATE', 'UPDATE']) is equivalent to declaring both @odata.before('CREATE') and @odata.before('UPDATE'). Passing '*' expands to all CRUD verbs (READ, CREATE, UPDATE, DELETE, LINK_NAVIGATION, UNLINK_NAVIGATION). When multiple operations are expanded, the hook scope is preserved only for the entries whose resolved operation is READ.
Example usage:
@odataController(Product)
export class ProductODataController {
constructor(@repository(ProductRepository) private products: ProductRepository) {}
// Validate and normalize payload before create
@odata.before('CREATE')
ensureName(ctx: CrudHookContext) {
const body = ctx.payload as any;
if (!body?.name) throw new HttpErrors.BadRequest('name is required');
body.name = String(body.name).trim();
}
// Enforce default ordering on list
@odata.before('READ', 'collection')
defaultOrder(ctx: CrudHookContext) {
ctx.filter = ctx.filter ?? {};
if (!ctx.filter.order) ctx.filter.order = ['updatedAt DESC'];
}
// Redact a field when returning a single entity
@odata.after('READ', 'entity')
redact(ctx: CrudHookContext) {
const entity = ctx.result as any;
if (entity) delete entity.secret;
}
// Override UPDATE. Call next() to delegate to default CRUD logic,
// or skip next() to fully replace the implementation.
@odata.on('UPDATE')
async customUpdate(ctx: CrudOnContext, next: () => Promise<any>) {
if ((ctx.payload as any)?.blocked) {
throw new HttpErrors.Forbidden('Blocked field');
}
// Augment default logic
return next();
}
// Fully custom collection read using helpers
@odata.on('READ', 'collection')
async customList(ctx: CrudOnContext, next: () => Promise<any>) {
if (!ctx.request.query['featured']) return next();
const items = await this.products.find({ where: { featured: true } }, ctx.options);
return ctx.helpers.collection(items);
}
// Reuse one hook across multiple write operations
@odata.before(['CREATE', 'UPDATE'])
stampWrites(ctx: CrudHookContext) {
ctx.state.lastWriteOp = ctx.operation;
}
// Run after hook on every operation
@odata.after('*')
auditAll(ctx: CrudHookContext) {
console.log('completed', ctx.operation);
}
}Notes:
before → on → afteris the execution order.@odata.beforeis the place for validation, authorization checks, or enriching/mutating incoming payload/filter data before the generated CRUD logic runs.@odata.onlets you replace or wrap the default CRUD handler, e.g., to call external REST APIs, implement custom persistence, or add business logic before delegating vianext().@odata.afteris ideal for post-processing responses, emitting audit logs, or firing side effects/events after a successful CRUD call but before the response is sent.- Scopes exist solely to split the single
READoperation into its three variants (collection,entity,$count). Non-read operations have no notion of scope, so the argument is ignored once a decorator entry resolves toCREATE,UPDATE,DELETE,LINK_NAVIGATION, orUNLINK_NAVIGATION. For example,@odata.before(['READ', 'UPDATE'], 'collection')runs on collection reads and on every update. @odata.oncan replace the generated logic by not callingnext(). Usectx.helpers.entity,ctx.helpers.collection,ctx.helpers.count, orctx.helpers.noContentto produce OData-correct responses when you override.- Hooks receive
CrudHookContextwithrequest,response,repository,options(including active transactions for$batch),payload/filter/id, and a mutablestatebag for passing data between phases. - Only one
@odata.onis allowed per operation/scope per controller; duplicates fail at boot. - Navigation reference routes trigger the dedicated operations
LINK_NAVIGATION(forPOST/PUT .../$ref) andUNLINK_NAVIGATION(forDELETE .../$ref). The hook context includesrelationName,navigationTargetId,navigationTargetKey, plusnavigationRelationRepository/navigationTargetRepositoryso you can enforce custom linking rules.
@odata.before('LINK_NAVIGATION')
blockDuplicateLinks(ctx: CrudHookContext) {
if (ctx.relationName !== 'items') return;
const header = ctx.request.get('x-block-link');
if (header?.toLowerCase() === 'true') {
throw new HttpErrors.Conflict('Link prevented by business rules.');
}
}
@odata.on('UNLINK_NAVIGATION')
async auditUnlink(ctx: CrudOnContext, next: () => Promise<unknown>) {
await next();
console.log('Unlinked', ctx.navigationTargetId, 'from order', ctx.id);
}Example: unbound action with a raw response
@odataAction({
name: 'resetInventoryRaw',
binding: 'unbound',
params: [{ name: 'confirm', type: 'Edm.Boolean' }],
rawResponse: true,
})
async resetInventoryRaw(body: { confirm?: boolean }) {
if (!body?.confirm) {
throw new HttpErrors.BadRequest('Pass {"confirm": true} to reset inventory');
}
await this.products.updateAll({ quantityOnHand: 0 });
return { status: 'ok' };
}This action is exposed as POST /odata/resetInventoryRaw, surfaces in $metadata as an unbound action, and because rawResponse is set, the controller controls the full payload. The sample application also includes a resetInventory action without rawResponse to illustrate the default OData envelope (payload returned under value with @odata.context).
Example: virtual/computed properties
LoopBack models can advertise computed fields by marking them as non-persistent. The extension will list the field in $metadata, but you are responsible for calculating it at runtime and ignoring any client-supplied values.
// order.model.ts
import { Entity, property } from '@loopback/repository';
import { odataModel } from '@urartian/loopback4-odata';
@odataModel({ entitySetName: 'Orders' })
export class Order extends Entity {
@property({ id: true })
id: string;
@property({ type: 'number', required: true })
amount: number;
@property({ type: 'string', required: true })
currency: string;
@property({ type: 'number', persist: false, jsonSchema: { readOnly: true } })
totalWithTax?: number;
}// order.odata-controller.ts
import { AnyObject, repository } from '@loopback/repository';
import { odata, CrudHookContext } from '@urartian/loopback4-odata';
import { Order } from './order.model';
import { OrderRepository } from './order.repository';
const addTotal = (entity: AnyObject) => {
const rate = entity.currency === 'EUR' ? 0.19 : 0.07;
const amount = Number(entity.amount ?? 0);
entity.totalWithTax = Number.isFinite(amount) ? +(amount * (1 + rate)).toFixed(2) : undefined;
return entity;
};
@odataController(Order)
export class OrderODataController {
constructor(@repository(OrderRepository) private readonly orders: OrderRepository) {}
@odata.before('CREATE')
@odata.before('UPDATE')
stripVirtual(ctx: CrudHookContext) {
if (ctx.payload) delete (ctx.payload as AnyObject).totalWithTax;
}
@odata.after('READ', 'entity')
addVirtualToEntity(ctx: CrudHookContext) {
const entity = ctx.result as AnyObject | undefined;
if (entity) addTotal(entity);
}
@odata.after('READ', 'collection')
addVirtualToCollection(ctx: CrudHookContext) {
const payload = ctx.result as { value?: AnyObject[] };
if (!payload?.value) return;
payload.value = payload.value.map((item) => addTotal(item));
}
}Key points:
- Declare the field on the model with
persist: falseso it is not stored in the datasource but still appears in$metadata. - Use
@odata.beforehooks to strip the field from incoming payloads. - Populate the computed value in an
@odata.afterhook (or@odata.onoverride) before the response is sent.
Using $batch
Send a JSON payload containing requests. When multiple entries share the same atomicityGroup, LoopBack executes them as a changeset and either commits or rolls everything back.
POST /odata/$batch
Content-Type: application/json
{
"requests": [
{
"id": "create",
"method": "POST",
"url": "/odata/Products",
"body": {"name": "Tablet", "price": 599},
"atomicityGroup": "g1"
},
{
"id": "update",
"method": "PATCH",
"url": "/odata/Products(1)",
"body": {"price": 1499},
"atomicityGroup": "g1"
}
]
}If the datasource behind the repositories cannot create transactions (for example, the in-memory connector), the OData component returns 501 Not Implemented with a BatchExecutionError explaining that the changeset could not be guaranteed. Use a transactional connector or omit atomicityGroup to execute requests independently.
The $batch parser enforces guardrails derived from config.batch: payload size (maxPayloadBytes, default 16 MB), total operations (maxOperations, default 100), operations per changeset (maxChangesetOperations, default 50), nesting depth (maxDepth, default 2 levels), and per-part payload size (maxPartBodyBytes, default 4 MB). Requests that exceed these limits short-circuit with 413 Payload Too Large (for size breaches) or 400 Bad Request (for operation limits), and the controller logs a structured warning so you can correlate rejections with client traffic. Tune the limits to match your back-end capacity—anything outside the allowed envelope is rejected before the atomic handlers run.
Optimistic Concurrency (ETags)
Add an ETag column to your LoopBack model and opt in by passing it to @odataModel. The property is typically a timestamp or version counter that you update whenever the record changes.
@odataModel({ etag: 'updatedAt' })
export class Product extends Entity {
@property({ id: true })
id!: number;
@property()
name!: string;
@property()
price!: number;
@property({ type: 'date', required: true, defaultFn: 'now' })
updatedAt!: Date;
}Keep the field fresh in your repository (for example, by stamping updatedAt in create/update hooks). The generated CRUD controller then:
- Emits
ETag: W/"…"headers and@odata.etagpayload metadata onGET /odata/Products(…). - Accepts optional
If-Matchheaders onPATCH/DELETE. When present, the update succeeds only if the token still matches the stored value; stale tokens return412 Precondition Failed. - Supports caching via
If-None-Matchon reads (GETresponds304 Not Modifiedwhen the token matches). - The ETag covers only the root entity unless you choose to update the parent token whenever child rows change.
You can pass an array of property names (@odataModel({etag: ['id', 'updatedAt']})) to build a composite token; the header value becomes a key/value list such as W/"id=42&updatedAt=2025-04-01T10%3A00%3A00.000Z".
Testing
Run npm test to compile the TypeScript specs and execute the unit suite. Acceptance specs leverage @loopback/testlab and will be skipped automatically in environments that disallow binding HTTP ports (for example, certain sandboxes). When running locally, the acceptance suite exercises the generated REST endpoints against a seeded in-memory datasource.
Features
- [x] OData-style entity paths (Products(1), Orders(OrderID=10248,CustomerID='ALFKI')) supported via middleware with compound and quoted key support
- [x] Auto-discovery of OData controllers (Booter)
- [x] Registry of entity sets
- [x] CRUD controller factory backed by LoopBack repositories
- [x] Service document exposing registered entity sets
- [x] $metadata endpoint with generated CSDL (including navigation properties for relations)
- [x] Basic query options → LoopBack filters (
$filter,$orderby,$top,$skip,$select) - [x] Extended filter support:
not, numeric functions (round,floor,ceiling), date extraction (year), string helpers (trim,concat), date parts (month,day,hour,minute,second) with strict-mode guards when unsupported, and$searchacross string fields - [x] any/all (lambdas): support
<nav>/(any|all)(x: <expr>)(including multi-segment paths),not <lambda>rewrite, nested lambdas (depth capped), and combining lambdas with other predicates using boolean trees (including top-levelor) - [x] Relational expansion via
$expand - [x] Inline and standalone
$count - [x]
$batchendpoint (JSON and multipart/mixed) - Transactions are attempted for changesets (
atomicityGroup). The component now caches whether each entity set's datasource can open LoopBack transactions: transactional connectors such as PostgreSQL succeed, while the in-memory connector is marked as non-transactional the first time a client attempts an atomicity group. Once a datasource is confirmed non-transactional the controller refuses future atomicity groups touching that entity set without instantiating its repository. When a datasource lacks transactions the affected changeset is rejected up front with501 Not Implementedand aBatchExecutionError; omitatomicityGroupto accept best-effort processing or switch to a transactional connector for true atomicity.$metadatasurfaces the service-wide capability via the EntityContainer annotationOrg.OData.Capabilities.V1.BatchSupported/ChangeSetsSupported(set totrueonly when every registered entity set is backed by a transactional datasource) and per-entity-set hints viaLoopBack.V1.BatchCapabilities.ChangeSetsSupported, so clients can decide whether to emit change sets globally or only for specific entity sets. - [x] Honors
Prefer: return=minimal|representationfor write operations and emitsOData-Version/Preference-Appliedheaders by default - [x] OData-compliant error payloads (
odata.error) with 501PreferenceNotSupportedfor unsupported preferences likerespond-async - [x] Actions & Functions decorators with auto CSDL generation
- [x] Proper pluralization of entity sets (via inflection)
- [x] Transaction-backed
$batchchangesets (when datasource supports transactions) - [x] Optimistic concurrency with OData ETags (
If-Match/If-None-Matchsupport on generated CRUD routes) - [x]
$batchexecution runs through the LoopBack pipeline so interceptors/auth apply; changesets use per-datasource transactions and commit/rollback as a unit - [x] Configurable base path (
basePath),$toplimit (maxTop),$counttoggle (enableCount), and guardrails for$skip/$expandvia config - [x] Opt-in
$searchwith boolean operators (AND/OR/NOT), quoted phrases, per-field configuration, and guardrails - [x] Configurable CSDL namespace/container names and JSON CSDL output with enriched primitive facets
- [x] Complex types, enum types, and referential constraints reflected in generated CSDL (XML & JSON)
- [x] Capabilities annotations (filter functions/restrictions, count/navigation restrictions, permissions, streams, insert/update/delete/search restrictions) to describe service behaviors to OData clients
- [x] Derived LoopBack models surface
$BaseTypeso inheritance is reflected in the generated CSDL - [x] Deep insert support for
hasOne/hasManyrelations (opt-in per entity set, multi-level traversal) - [x] Navigation
$refendpoints forhasOne/hasManyrelations (link/unlink existing entities) - [x] OpenAPI visibility controls via per-model
documentInOpenApiflags and global policies (documentInOpenApiDefault,removeUndocumentedFromSpec) - [x] Media streams via
$valueroutes forHasStreamentity sets (property-backed binary fields or custom repository adapters) - [x] Structured telemetry with request logging, apply/rewrite diagnostics, hook traces, and correlation IDs (opt-in via config)
Queries can now combine boolean operators and phrases:
GET /odata/Products?$search="coffee beans" AND grinder NOT decafThe example above matches products that include the phrase "coffee beans", also mention "grinder", and omit anything containing "decaf".
String helpers such as trim/concat and date part functions (month, day, hour, minute, second) are processed automatically when strict=false. In strict mode they are accepted only when the server can guarantee correct execution (for example via Postgres pushdown); otherwise they return 400 Bad Request.
Configuration
Customize the OData component via ODataConfig bound at odata.config (the component registers a default). You can override it in your application before boot:
import { ODATA_BINDINGS } from '@urartian/loopback4-odata';
import { ODataConfig } from '@urartian/loopback4-odata';
// inside your app setup
this.bind(ODATA_BINDINGS.CONFIG).to({
basePath: '/api/odata', // default: '/odata'
csdlFormat: 'xml', // 'xml' | 'json' (default 'xml')
namespace: 'Catalog', // default: 'Default'
entityContainerName: 'CatalogService', // default: 'DefaultContainer'
namespaceAlias: 'CatalogNS',
tokenSecret: process.env.ODATA_TOKEN_SECRET!, // required for signed paging/delta tokens (auto-generated per boot outside production when omitted)
capabilities: {
filterFunctions: ['contains', 'startswith', 'endswith'],
countable: true,
aggregation: true,
},
pagination: {
maxTop: 100, // server paging cap
maxSkip: 1000, // max skip allowed
maxPageSize: 50, // server-driven paging guardrail
maxApplyPageSize: 100, // guardrail for $apply pipelines
},
pageSize: 50, // default server-driven paging size (default: 200)
maxExpandDepth: 2, // max $expand nesting depth
enableCount: true, // enable inline and standalone $count
strict: true, // enable strict validations (default: true)
enableDelta: true, // emit $deltatoken links for incremental syncs
batch: {
maxPayloadBytes: 16 * 1024 * 1024, // total request size limit
maxOperations: 100, // total requests allowed per batch
maxChangesetOperations: 50, // per-changeset limit
maxPartBodyBytes: 4 * 1024 * 1024, // individual part payload limit
maxResponseBodyBytes: 4 * 1024 * 1024, // per-sub-response buffering limit
maxResponsePayloadBytes: 32 * 1024 * 1024, // aggregate buffered + serialized response limit
allowedSubRequestHeaders: ['if-match', 'prefer'], // optional extra headers sub-requests may override
subRequestTimeoutMs: 30_000, // abort sub-requests that exceed this duration
},
writeTransactions: {
enabled: true, // wrap non-$batch writes in a datasource transaction when supported
rejectMultiDataSource: true, // default: true (reject writes spanning multiple datasources)
},
onLog(entry) {
myTelemetryClient.trackEvent({
name: 'odata-log',
properties: {
level: entry.level,
message: entry.message,
...entry.context,
},
});
},
} as ODataConfig);Capabilities presets (FilterFunctions)
The service advertises supported $filter functions in $metadata via Org.OData.Capabilities.V1.FilterFunctions. To keep this aligned with the library’s implementation without maintaining long arrays, you can use a preset (no runtime connector auto-detection):
filterFunctionsPreset: 'default': legacy/minimal set (matches previous default behavior)filterFunctionsPreset: 'postgres': Postgres-ready set aligned with implemented pushdowns (includestrim,concat,month, etc.)
import { ODATA_BINDINGS, ODataConfig, FILTER_FUNCTIONS_POSTGRES } from '@urartian/loopback4-odata';
const currentConfig = this.getSync(ODATA_BINDINGS.CONFIG) as ODataConfig;
this.bind(ODATA_BINDINGS.CONFIG).to({
...currentConfig,
capabilities: {
...currentConfig.capabilities,
filterFunctionsPreset: 'postgres',
},
} satisfies ODataConfig);
// or explicitly:
this.bind(ODATA_BINDINGS.CONFIG).to({
...currentConfig,
capabilities: {
...currentConfig.capabilities,
filterFunctions: FILTER_FUNCTIONS_POSTGRES,
},
} satisfies ODataConfig);Notes:
FilterFunctionsonly advertises$filterfunctions. Operators likein (...)are not represented there. Presets and lists are normalized (trimmed, lowercased, de-duplicated).
$filter not implemented (yet)
This library focuses on a predictable subset of $filter that can be enforced in strict=true and pushed down efficiently on Postgres. If a client sends anything outside this subset, behavior is:
strict=true: rejected with400 Bad Requeststrict=false: may still be rejected (or bounded post-filtered) depending on whether the expression can be evaluated safely
Supported $filter summary
At a high level, the server supports:
- Boolean logic: nested
and/or/notwith parentheses - Comparisons:
eq/ne/gt/ge/lt/le, includingnull/true/false - Typed literals + model-aware coercion:
guid'...',date'...',datetimeoffset'...',int64'...'(and123L),decimal'...' in (...): parsed asinqwithcapabilities.filter.maxInListItemsguardrail (null-safe)- String filtering:
contains/startswith/endswith, and directtolower(...)/toupper(...)comparisons (plus Postgres pushdown for supported patterns) - Date parts:
month/day/hour/minute/secondcomparisons (Postgres pushdown) - Navigation paths:
- To-one chains (
belongsTo/hasOne) ending in primitive fields are supported (Postgres pushdown when available; bounded fallback otherwise) - To-many navigation filters are supported only via lambdas (
any/all)
- To-one chains (
- Lambdas (
any/all): nested (depth capped), top-levelorsupport, Postgres pushdown when enabled, and bounded fallback when pushdown declines
For the exact set of supported $filter functions, rely on $metadata (Org.OData.Capabilities.V1.FilterFunctions) or the filterFunctionsPreset presets (default / postgres).
$filter examples
Basic comparisons:
GET /odata/Products?$filter=price ge 100 and active eq trueTyped literals (model-aware coercion):
GET /odata/Orders?$filter=customerId eq guid'01234567-89ab-cdef-0123-456789abcdef'
GET /odata/Orders?$filter=createdAt ge datetimeoffset'2026-01-03T10:20:30Z'in (...) (null-safe; subject to filter.maxInListItems):
GET /odata/Products?$filter=status in ('Open','Closed',null)String filtering:
GET /odata/Products?$filter=contains(name,'lap')
GET /odata/Products?$filter=tolower(name) eq 'laptop'To-one navigation path filters (Postgres pushdown when available; otherwise bounded fallback):
GET /odata/Orders?$filter=customer/name eq 'Alice'&$top=50To-many navigation filters via lambdas:
GET /odata/Orders?$filter=items/any(i: i/quantity gt 0)
GET /odata/Orders?$filter=items/all(i: i/cancelled eq false)Notable $filter features that are currently not implemented:
- Type functions:
cast(...),isof(...) - String functions:
replace(...)(and most other string functions beyond what$metadataadvertises) - Arithmetic expressions/operators in
$filter(add,sub,mul,div,mod) - Spatial/geo functions (all
geo.*and geography/geometry operators) - Enum flag operator
has - Deep function composition (e.g.
replace(tolower(name), 'a', 'b') eq '...') - To-many navigation path filters outside lambdas (e.g.
items/quantity gt 0); only lambda forms likeitems/any(i: i/quantity gt 0)are supported
Capabilities defaults (FilterRestrictions)
The service also emits Org.OData.Capabilities.V1.FilterRestrictions per entity set. By default, it marks any to-many navigation properties (hasMany / hasManyThrough) as non-filterable (since the server rejects to-many navigation filters outside lambdas).
You can add additional hints (or override booleans) via config:
this.bind(ODATA_BINDINGS.CONFIG).to({
...currentConfig,
capabilities: {
...currentConfig.capabilities,
filterRestrictions: {
requiresFilter: false,
nonFilterableProperties: ['internalFlag'],
},
},
} satisfies ODataConfig);For mixed datasources (or per-entity differences), prefer per-entity-set overrides:
import { FILTER_FUNCTIONS_POSTGRES, type ODataEntitySetConfig } from '@urartian/loopback4-odata';
export const PurchasesSet: ODataEntitySetConfig = {
name: 'Purchases',
modelCtor: Purchase,
capabilities: { filterFunctions: FILTER_FUNCTIONS_POSTGRES },
};Important:
capabilitiesis a nested object. Flags such asaggregation,applySupported, orfilterFunctionsbelong underconfig.capabilities. If you bind a brand-new config object without copying the defaults registered byODataComponent, those flags disappear and features like$applyaggregations are reported as not implemented. Preferthis.getSync(ODATA_BINDINGS.CONFIG)and spread the existing value before applying overrides.
const currentConfig = this.getSync(ODATA_BINDINGS.CONFIG) as ODataConfig;
this.bind(ODATA_BINDINGS.CONFIG).to({
...currentConfig,
maxTop: 100,
enableDeepInsert: true,
capabilities: {
...currentConfig.capabilities,
aggregation: true,
applySupported: true,
filterFunctions: ['contains', 'startswith', 'endswith'],
},
} satisfies ODataConfig);Spreading the current config ensures sensitive settings such as tokenSecret remain intact unless you explicitly replace them.
Per-entity guardrails can be applied through the entity-set config you register with the registry binding. Entity-level limits override the global pagination block, allowing you to relax or tighten caps on a per-feed basis:
import { type ODataEntitySetConfig } from '@urartian/loopback4-odata';
const ProductsSet: ODataEntitySetConfig<Product> = {
name: 'Products',
modelCtor: Product,
repositoryBindingKey: 'repositories.ProductRepository',
pagination: {
maxTop: 500,
maxPageSize: 250,
maxApplyPageSize: 100,
},
};Validation: Guardrail values must be positive integers. Invalid settings (for example,
pagination.maxPageSize: 0orskipTokenTtl: -5) cause startup to fail fast so configuration issues surface immediately.
basePath: Externally visible service root. All OData routes are served under this path (via middleware rewrite) while internal routes remain at/odata. Response metadata (@odata.context) uses this value.trustProxyHeaders: Controls whetherForwarded/X-Forwarded-*headers participate in host/protocol detection. Set totrueto always trust them, orfalse/omitted to ignore them entirely (this now ignores Express' globaltrust proxyflag so the OData component cannot be opted-in accidentally). When disabled, the controller only considers the immediate request'sHostandprotocolvalues, preventing spoofed headers from bypassing origin scoping.trustedProxySubnets: CIDR/IP allow-list (string[]) describing which reverse proxies are allowed to supplyForwarded/X-Forwarded-*headers (for example['10.0.0.0/8', '2001:db8::/32']). Defaults to[], meaning proxy headers are ignored unlesstrustProxyHeaders === true. When populated, the controller only honors proxy headers if the remote address matches a configured subnet, so direct-to-app attackers cannot spoof origins even when the app runs behind ingress.pagination.maxTop: Caps$topfor collection reads. Whenstrict=truerequests above the cap return400 Bad Request; otherwise the server clamps the value. Legacyconfig.maxTopis still honored but the nested value takes precedence.pagination.maxSkip: Maximum allowed$skip. Requests above the cap are clamped whenstrict=falseand rejected whenstrict=true. Legacyconfig.maxSkipremains available for backward compatibility.pagination.maxPageSize: Upper bound for server-driven paging on collection endpoints. The service never emits more than this many entities in a single page even when clients omit$top.pagination.maxApplyPageSize: Upper bound for server-driven paging when executing$applypipelines. When unset, it falls back tomaxPageSize.pageSize: Default number of records per page for server-driven paging. The service always returns at most this many entities and emits an@odata.nextLinkwith a signed$skiptokenso clients can resume the feed. Automatically clamped to the configured pagination guardrails.appendKeysForClientPaging: Whentrue(default) the controller appends the entity key columns to client-supplied$orderbyclauses whenever requests use manual$skip. This keeps offset-based paging stable for frameworks that ignore@odata.nextLink(for example, SAPUI5 growing tables). Set tofalseonly if you need the backend to preserve the original order verbatim even at the cost of potential duplicates across pages.- Manual paging: Supplying both
$skip(even0) and a positive$topswitches the request into client-driven paging. In that mode the backend clamps$topto the configured page size, appends key columns for deterministic ordering, and suppresses@odata.nextLink. Clients must increment$skipthemselves to fetch more rows. If$skipis sent without$top, the controller sticks with server-driven paging and still emits@odata.nextLink. enableDelta: Whentrue, collection responses include@odata.deltaLinkso clients can poll only the rows that changed since the last snapshot.tokenSecret: Required secret used to sign$skiptoken/$deltatokenpayloads. WhenNODE_ENVorODATA_ENVisproduction,ODATA_TOKEN_SECRETmust be set or the app refuses to boot. In non-production environments the component auto-generates a per-boot random secret if none is provided and logs an INFO message; pagination/delta links expire on every restart until you setODATA_TOKEN_SECRETyourself (for exampleexport ODATA_TOKEN_SECRET=$(openssl rand -hex 32)).skipTokenTtl: Lifetime (in seconds) for issued$skiptokenlinks. Defaults to900(15 minutes). Expired tokens return400 Invalid $skiptoken.deltaTokenTtl: Optional lifetime (seconds
