npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@iappx/entity-repo-rest

npm CI license node

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-rest

Installing 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-rest

How 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 · cursor

Each 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


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=40

The 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=activeeq 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 supported

Which 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=c1

Worked 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]=active

Two 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(…) and f.any('books', …) were rejected a minute ago and compile now, because QueryValidator is 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/accounts

A missing path parameter raises RestUrlError instead of producing a url with a literal :userId in it.

Transports

FetchTransport speaks TRestRequestTRestResponse 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.