@iappx/entity-repo-rest
v1.0.1
Published
The REST dialect for @iappx/entity-repo: compile a query AST into HTTP requests with swappable encoders, url builders, request factories and response adapters.
Maintainers
Readme
@iappx/entity-repo-rest
Your REST API has its own idea of a query string. This package has none.
The REST dialect for @iappx/entity-repo: it turns the
@iappx/entity-repo-query AST into an HTTP request. Every step of that
translation — filters, ordering, paging, selection, the url, the method, where the query lands, how the answer is read —
is an interface with a thin default behind it.
npm install @iappx/entity-repo-restInstalling this package pulls the whole read/write path into place — you write entities and query chains, nothing else.
The packages
| Package | What it is |
| --- | --- |
| @iappx/entity-repo | The core. Entities, metadata, change tracking, contexts, transport interfaces. |
| @iappx/entity-repo-query | The typed builder and the query AST this package compiles, plus the capability validator. |
| @iappx/entity-repo-rest (this one) | The REST dialect: AST → HTTP request → entities back. |
| @iappx/entity-repo-gql | The GraphQL dialect. Same entities, same query chain, a document instead of a url. |
| @iappx/gql-builder | The document builder behind the GraphQL dialect. Useful on its own. |
Both peers are required at runtime — install them alongside this package:
npm install @iappx/entity-repo @iappx/entity-repo-query @iappx/entity-repo-restHow it works
The query arrives as data. A chain of small strategies turns it into a request, and nothing in between is hardcoded.
entity query encoders request transport
┌────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ .where(f => …) │ │ IFilterEncoder │ │ IUrlBuilder │ │ ITransport │
│ .orderBy(…) │ → │ IOrderEncoder │ → │ IRequestFactory │ → │ FetchTransport │
│ .take(20) │ │ IPagingEncoder │ │ IRequestMiddle… │ │ axios · ky · … │
└────────────────┘ │ ISelectionEncoder│ └──────────────────┘ └─────────────────┘
TQueryAst └──────────────────┘ TRestRequest ↓
params + body IResponseAdapter
capability slices items · total · cursorEach encoder declares what it can express; the dialect's capabilities are the sum of the encoders actually plugged in. Swap one for a richer one and the queries it can compile become legal — automatically.
Highlights
| | |
| --- | --- |
| 🧩 No wire format inside | ?status=active&limit=20 is a default, not a decision. Every shape is one class away. |
| 🔌 Nine extension points | Filter, order, paging, selection, url, request, response, middleware, transport. |
| 📈 Capabilities are composed | Plug in an encoder that understands or and QueryValidator stops rejecting or. No flag to flip. |
| 🎯 Query string or body | GET /accounts?… and POST /accounts/search are the same query with a different IRequestFactory. |
| 🪶 No runtime dependencies | FetchTransport uses the platform fetch. Axios, ky or undici is a five-line class. |
| 🧾 Payloads come from the entity | Writes go through entity.serialize({ operation, onlyChanged }) — readonly, generated and client-only fields are handled by the core. |
| 🧊 Immutable queries | An entity set never leaks the state of an earlier query, escape hatches included. |
| 🚪 A way out at every level | rawFilter · withMeta · withQueryParams · withHeaders · withPathParams · rawRequest. |
| 🚦 Fails before the socket | An unsupported query raises QueryValidationError with every reason at once, before a request is built. |
Requirements
- Node.js >= 18
- TypeScript with
experimentalDecorators @iappx/entity-repo>= 3.2.0 and@iappx/entity-repo-query>= 1.1.0 (peer dependencies)
Quick start
1. Describe an entity
Fields live on the prototype as accessors, so under strict they need a definite assignment assertion (!):
import { RepoEntityBase, RepoEntityField } from '@iappx/entity-repo'
export class Account extends RepoEntityBase<Account> {
@RepoEntityField({ isPrimaryKey: true, isGenerated: true })
public id!: string
@RepoEntityField()
public email!: string
@RepoEntityField()
public status!: 'active' | 'banned'
@RepoEntityField({ isReadonly: true })
public createdAt!: string
}2. Wire the entity set
import { EntityContextBase, EntityRepo, ITransport, NamingStrategies, RepoEntitySet } from '@iappx/entity-repo'
import { FetchTransport, RestEntityQuery, TRestRequest } from '@iappx/entity-repo-rest'
class AppContext extends EntityContextBase<ITransport<TRestRequest>> {
@RepoEntitySet(() => Account, () => RestEntityQuery, {
resource: '/accounts',
naming: NamingStrategies.snakeCase,
})
public accounts!: RestEntityQuery<Account>
}
const context = EntityRepo.create()
.use(AppContext, new FetchTransport({ baseUrl: 'https://api.test/v1' }))
.getContext(AppContext)3. Query
const accounts = await context.accounts
.where(f => f.eq('status', 'active'))
.orderBy('createdAt', 'desc')
.take(20)
.skip(40)
.getAll()GET https://api.test/v1/accounts?status=active&sort=-created_at&limit=20&offset=40The answer is fed through Account.build(data, { naming }), so the entities come back unchanged — a later patch
sends only what your code touched afterwards.
The defaults are deliberately small
What ships out of the box is the least an API can offer, so that nothing is assumed about yours:
| Piece | Default | Produces |
| --- | --- | --- |
| IFilterEncoder | FlatFilterEncoder | ?status=active — eq only, and only |
| IOrderEncoder | FlatOrderEncoder | ?sort=-created_at,email |
| IPagingEncoder | OffsetPagingEncoder | ?limit=20&offset=40 |
| ISelectionEncoder | FieldsSelectionEncoder | ?fields=id,email (only when the query asks for a selection) |
| IUrlBuilder | TemplateUrlBuilder | /accounts and /accounts/:id |
| IRequestFactory | QueryStringRequestFactory | GET · POST · PUT · PATCH · DELETE, query in the query string |
| IResponseAdapter | ArrayResponseAdapter | a bare JSON array, total from X-Total-Count |
| ITransport | FetchTransport | the platform fetch |
Composed, they declare exactly this much:
{ operators: ['eq'], logical: { or: false, not: false, nesting: true },
relationFilters: { enabled: false, quantifiers: [] }, relationArguments: false,
paging: ['offset'], ordering: { multiple: true, nulls: false, byPath: false },
selection: 'flat', raw: true }Anything richer than that is rejected before a request is built:
QueryValidationError: RestDialect cannot compile the query
- operator "gte" is not supported (path: rating)
- logical "or" is not supportedWhich is the point: the defaults are a starting line, not a guess about your backend.
The extension points
Every one of them is chosen per entity set through the @RepoEntitySet options and can be overridden per query.
| Interface | Question it answers | Ships with |
| --- | --- | --- |
| IFilterEncoder | How does a condition look on the wire? | FlatFilterEncoder |
| IOrderEncoder | How is ordering expressed? | FlatOrderEncoder |
| IPagingEncoder | How is a page requested? | OffsetPagingEncoder |
| ISelectionEncoder | How is a field set requested? | FieldsSelectionEncoder |
| IUrlBuilder | Which path does this operation hit? | TemplateUrlBuilder |
| IRequestFactory | Which method, and where does the query go? | QueryStringRequestFactory |
| IResponseAdapter | Where are the items, the total, the cursor? | ArrayResponseAdapter · EnvelopeResponseAdapter |
| IRequestMiddleware | What wraps every request? | StaticHeadersMiddleware |
| IRequestRewriter | The last word on the built request. | (none — that is what rawRequest is for) |
| ITransport<TRestRequest> | Who actually sends it? | FetchTransport |
| IQueryStringSerializer | How are parameters written out? | QueryStringSerializer |
| IParamTextEncoder | How is a parameter escaped? | ComponentParamEncoder · RawParamEncoder |
| IParamValueFormatter | How does a value become a parameter? | ParamValueFormatter |
| IHttpClient | Which fetch is called? | GlobalFetchClient |
An encoder returns a TEncodedQuery — { params, body? }. Producing a body fragment instead of parameters is how a
JSON-body dialect is built, and both halves reach the request factory.
A filter encoder is a FilterVisitor, so a new node kind can never be silently forgotten. Two ways to write one:
| The encoder is | Write it as | Example |
| --- | --- | --- |
| stateless | its own visitor: extends FilterVisitor<TEncodedQuery, CompileContext>, and encode is this.visit(node, context) | FlatFilterEncoder |
| stateful per compile (an index, a path prefix) | extends FilterEncoderBase and return a fresh visitor from createVisitor(context) | the worked example below |
Which paging does your api speak?
take(20) has to become something. The entity set decides which, so a bare take never produces a paging node the
encoder cannot express:
{
pagingKind: 'cursor', // 'offset' (default) · 'page' · 'cursor'
pagingEncoder: new CursorPagingEncoder(), // yours — the shipped one is offset only
}await context.accounts.take(10).after('c1').getAll() // ?first=10&after=c1
await context.accounts.last(10).before('c1').getAll() // ?last=10&before=c1Worked example: filter[0][field]=…
This is the format the package deliberately does not ship, and it is the reason for every interface above. One class, one line of wiring, no fork.
import {
CompileContext, FilterVisitor, QueryOperators,
TComparisonNode, TLogicalNode, TRawFilterNode, TRelationFilterNode, UnsupportedOperationError,
} from '@iappx/entity-repo-query'
import { EncodedQueryMerger, FilterEncoderBase, TCapabilitySlice, TEncodedQuery, TRestQueryParams } from '@iappx/entity-repo-rest'
class IndexedTupleVisitor extends FilterVisitor<TEncodedQuery> {
private prefix: string[] = []
private index: number = 0
constructor(private readonly context: CompileContext) {
super()
}
protected visitComparison(node: TComparisonNode): TEncodedQuery {
const resolved = this.context.resolve([...this.prefix, ...node.path])
const params: TRestQueryParams = {}
params[`filter[${this.index}][field]`] = resolved.sourcePath.join('.')
params[`filter[${this.index}][operation]`] = IndexedTupleVisitor.symbol(node.operator)
params[`filter[${this.index}][value]`] = String(this.context.serialize(node.value, resolved.attribute))
this.index = this.index + 1
return { params }
}
protected visitLogical(node: TLogicalNode): TEncodedQuery {
const start = this.index
const merged = EncodedQueryMerger.merge(...this.visitAll(node.nodes))
if (node.operator === 'or') {
for (let i = start + 1; i < this.index; i++) {
merged.params[`filter[${i}][join]`] = 'or'
}
}
return merged
}
protected visitRelation(node: TRelationFilterNode): TEncodedQuery {
this.prefix = [...this.prefix, ...node.path]
const encoded = this.visit(node.node)
this.prefix = this.prefix.slice(0, this.prefix.length - node.path.length)
return encoded
}
protected visitRaw(node: TRawFilterNode): TEncodedQuery {
return { params: { ...(node.payload as TRestQueryParams) } }
}
private static symbol(operator: string): string {
const symbols: Record<string, string> = {
[QueryOperators.eq]: '=', [QueryOperators.ne]: '!=', [QueryOperators.gt]: '>',
[QueryOperators.gte]: '>=', [QueryOperators.lt]: '<', [QueryOperators.lte]: '<=',
}
if (!symbols[operator]) {
throw new UnsupportedOperationError(`AcmeApi cannot compile "${operator}"`)
}
return symbols[operator]
}
}
export class IndexedTupleFilterEncoder extends FilterEncoderBase {
public readonly capabilities: TCapabilitySlice = {
operators: 'all',
logical: { or: true, not: false, nesting: true },
relationFilters: { enabled: true, quantifiers: ['single', 'any'] },
raw: true,
}
protected createVisitor(context: CompileContext): FilterVisitor<TEncodedQuery> {
return new IndexedTupleVisitor(context)
}
}The line of wiring:
@RepoEntitySet(() => Account, () => RestEntityQuery, {
resource: '/accounts',
naming: NamingStrategies.snakeCase,
filterEncoder: new IndexedTupleFilterEncoder(),
})
public accounts!: RestEntityQuery<Account>await context.accounts.where(f => f.and(f.gt('rating', 0), f.eq('status', 'active'))).getAll()filter[0][field]=rating&filter[0][operation]=>&filter[0][value]=0
filter[1][field]=status&filter[1][operation]==&filter[1][value]=activeTwo things happened without a single line of configuration:
- the ordering, paging and selection encoders were untouched —
?sort=…&limit=…still works exactly as before; - the capabilities rose.
f.or(…)andf.any('books', …)were rejected a minute ago and compile now, becauseQueryValidatoris fed the sum of the plugged-in encoders. Extending the class is what changes the contract.
(Brackets stay readable in the query string; > is escaped to %3E as RFC 3986 asks. If your API insists on the raw
character, pass new QueryStringSerializer({ encoder: new RawParamEncoder() }) to the transport.)
Moving the query into a body
POST /accounts/search is the same query with a different request factory. The factory sees the compiled query and the
url, and decides everything else:
export class SearchBodyRequestFactory implements IRequestFactory {
private readonly fallback = new QueryStringRequestFactory()
public create(context: TRequestContext): TRestRequest {
if (context.kind !== 'getAll' && context.kind !== 'getPage' && context.kind !== 'count') {
return this.fallback.create(context)
}
return {
method: 'POST',
url: `${context.url}/search`,
body: { ...context.query.body, ...context.query.params },
}
}
}Pair it with a filter encoder that emits { params: {}, body: { filter: … } } and the whole query travels as JSON,
while writes keep using plain POST /accounts. context.kind is one of getAll · getOne · getPage · count ·
create · update · patch · delete · updateMany · deleteMany.
Urls
TemplateUrlBuilder renders :param templates. Without templates it uses resource and resource/:id.
{
resource: '/accounts',
baseUrl: 'https://api.test/v1',
endpoints: {
collection: '/users/:userId/accounts',
single: '/users/:userId/accounts/:id',
},
}await context.accounts.withPathParams({ userId: 3 }).getAll() // /users/3/accountsA missing path parameter raises RestUrlError instead of producing a url with a literal :userId in it.
Transports
FetchTransport speaks TRestRequest → TRestResponse and has no dependencies:
new FetchTransport({
baseUrl: 'https://api.test/v1',
headers: { accept: 'application/json' },
serializer: new QueryStringSerializer({ arrayFormat: 'bracket' }),
})Anything else is a class with one method:
export class AxiosTransport implements ITransport<TRestRequest> {
public async send<TRes>(request: TRestRequest): Promise<TRes> {
const response = await axios.request({
method: request.method,
url: request.url,
params: request.query,
headers: request.headers,
data: request.body,
})
return { status: response.status, headers: response.headers, data: response.data } as unknown as TRes
}
}A transport that answers with the bare payload works too — anything that is not a { status, headers, data } object is
wrapped as one before it reaches the response adapter.
Reading the answer
| Answer | Adapter |
| --- | --- |
| [ … ], total in X-Total-Count | new ArrayResponseAdapter() |
| { data: [ … ], total } | new EnvelopeResponseAdapter() |
| { items: [ … ], meta: { count } } | new EnvelopeResponseAdapter({ itemsKey: 'items', metaKey: 'meta', totalKey: 'count' }) |
| Content-Range · Link · anything else | your own IResponseAdapter — four methods: items single total cursor |
Middleware
An ordered chain around every request. Composable, and free to answer without touching the transport at all:
export class BearerTokenMiddleware implements IRequestMiddleware {
constructor(private readonly tokens: TokenStore) {
}
public async handle(request: TRestRequest, next: IRequestHandler): Promise<TRestResponse> {
const token = await this.tokens.current()
return next.handle({ ...request, headers: { ...request.headers, authorization: `Bearer ${token}` } })
}
}{ middleware: [new BearerTokenMiddleware(tokens), new RetryMiddleware(3), new LoggingMiddleware()] }Reading and writing
| Method | Request |
| --- | --- |
| getAll() | GET /accounts?… |
| getOne(key) | GET /accounts/:key — carries the selection, not the paging |
| getPage() | GET /accounts?… → { items, total?, cursor? } |
| count() | GET /accounts?… → the total the adapter reports |
| create(entity) | POST /accounts with entity.serialize({ operation: 'create' }) |
| update(entity) | PUT /accounts/:pk with every writable field |
| patch(entity) | PATCH /accounts/:pk with the changed fields only |
| delete(entity \| key) | DELETE /accounts/:pk |
| updateMany(values) | PATCH /accounts?… with the compiled filter |
| deleteMany() | DELETE /accounts?… with the compiled filter |
Payloads are never hand-rolled: isReadonly, isGenerated and isClientOnly fields are dropped by the core, and
patch sends entity.getChangedKeys() only. updateMany maps its keys through the same naming strategy and raises
UnknownFieldError on a typo.
await context.accounts.where(f => f.eq('status', 'active')).updateMany({ status: 'banned' })Escape hatches
| Level | Way out |
| --- | --- |
| Condition | rawFilter(payload, dialect?) — merged into the parameters by the default encoder |
| Query | withMeta(meta) — reaches CompileContext, the request factory and request.meta |
| Query string | withQueryParams({ include: 'books' }) |
| Headers | withHeaders({ 'x-tenant': '7' }) |
| Path | withPathParams({ userId: 3 }) |
| Cancellation | withSignal(controller.signal) |
| The request itself | rawRequest(rewriter) — the last word, after the factory |
class ForceGetRewriter implements IRequestRewriter {
public rewrite(request: TRestRequest, context: TRequestContext): TRestRequest {
return { ...request, method: 'GET', url: `${context.url}/legacy` }
}
}
await context.accounts.rawRequest(new ForceGetRewriter()).getAll()Rewriters registered on the entity set run first, per-query ones after, in the order they were added. All of it survives
.where(…), .take(…) and every other builder call, and none of it leaks back into the entity set.
Subclassing the entity query
When an api needs something no option covers, RestEntityQuery is a normal class. The hooks it answers come from
QueryableEntityQuery, so a subclass overrides one method instead of reimplementing a pipeline:
| Hook | Answers |
| --- | --- |
| naming() | The naming strategy handed to CompileContext and to Entity.build / serialize. |
| values() | The ValueSerializer for custom scalars. |
| meta() | The meta the encoders can read off the compile context. |
| pagingKind() | What a bare take(n) becomes. |
| dialect() | The whole strategy bundle. |
| copyStateTo(query) | Per-query state your subclass adds, so it survives every builder call. |
export class TenantAccountQuery extends RestEntityQuery<Account> {
protected tenant?: string
public forTenant(tenant: string): this {
const query = this.withHeaders({ 'x-tenant': tenant })
query.tenant = tenant
return query
}
protected copyStateTo(query: this): void {
super.copyStateTo(query)
query.tenant = this.tenant
}
}Errors
| Error | Raised when |
| --- | --- |
| QueryValidationError | The composed capabilities cannot express the query. Carries every reason. |
| UnsupportedOperationError | An encoder hits a node it cannot compile. |
| UnknownFieldError | A path or a value map names a field the entity does not declare. |
| RestUrlError | An endpoint template is missing a path parameter, or the entity set has no resource. |
| RestRequestError | The api answered with a failure status. Carries status, url and the parsed body. |
| RestResponseError | The answer does not hold what the operation needs (no list, no total). |
License
MIT — see LICENSE.
