README.md
August 12, 2026 · View on GitHub
rapiq
Typed REST queries: build, transport, validate, execute.
Rapiq (Rest Api Query) builds an efficient interface between applications:
browser ↔ API just as well as service ↔ service. It defines a scheme for the request, but not for the response.
Documentation · Getting Started · Guide · Migration from v1
Note
The documentation for v1 (and prior) lives on the v1 branch.
Why rapiq?
Every REST list endpoint answers the same five questions: which fields, which filters, which relations, which page, which order. rapiq turns them into one typed pipeline instead of ad-hoc string parsing.
- 🧭 Typed end to end: every field path in
defineQuery<User>is checked against the record type; condition helpers (eq,gte,and,or, …) replace magic value strings. - 🛡️ The receiving side has the last word: a
Schemadeclares what a caller may request per parameter (allow-lists, defaults, mappings). Invalid input is dropped or rejected according to the parser dialect and schema policy, and server conditions (query.filters.and(...)) remain conjuncts alongside caller input. - 🔁 Loss-free transport: within each codec dialect,
decode(encode(query))restores the same query; outside its subset, encoding fails loudly with a typed error instead of silently changing semantics. - 🔌 Any backend: the same AST executes everywhere: parameterized SQL fragments with presets for Postgres, MySQL, SQLite, MSSQL & Oracle, a TypeORM
SelectQueryBuilder, a PrismafindManyargs object, a drizzle relational-queries config, or compiled functions over in-memory data. - 📦 Composable packages: no monolith, install only what each side needs;
@rapiq/coreis the single shared foundation.
The two ends are just applications. A browser querying an API is the common case, but services compose the same way:
an API gateway, for instance, validates an incoming query against its own schema, scopes it
(query.filters.and(...)) and re-encodes it for the upstream service.
Usage
One query, from the caller all the way to the database. Both sides share the same record types:
type Realm = { id: string, name: string };
type User = { id: number, name: string, email: string, age: number, realm: Realm };
1. Build 🔧 calling application
defineQuery takes typed input and returns a
Query AST: no magic value strings, every path checked against User.
import { defineQuery } from '@rapiq/core';
import { createURLCodec } from '@rapiq/codec-url';
const query = defineQuery<User>({
fields: ['id', 'name'],
filters: { age: { $gte: 18 } },
relations: ['realm'],
sorts: '-id',
pagination: { limit: 20 },
});
await fetch(`/users?${createURLCodec().encode(query)}`);
⬇ over the wire self-described, so the receiver knows how to read it
codec=url-expression&fields=id,name&filter=gte(age,'18')&page[limit]=20&include=realm&sort=-id
2. Validate 🛡️ receiving application
A Schema is the allow-list: it decides what a caller may ask for, per parameter.
import { SchemaRegistry, defineSchema } from '@rapiq/core';
const registry = new SchemaRegistry();
registry.add(defineSchema<Realm>({
name: 'realm',
fields: { allowed: ['id', 'name'] },
}));
registry.add(defineSchema<User>({
name: 'user',
fields: { allowed: ['id', 'name', 'email'] },
filters: { allowed: ['id', 'name', 'age'] },
relations: { allowed: ['realm'] },
sorts: { allowed: ['id', 'name'] },
pagination: { maxLimit: 20 },
schemaMapping: { realm: 'realm' },
}));
// accepts a raw query string or a pre-parsed object (express req.query)
const query = createURLCodec(registry).decode(req.query, { schema: 'user' });
Anything the schema does not allow never reaches step 3: it is dropped or rejected, per the schema's failure policy.
⬇ applied by an adapter
3. Execute 🗄️ database
import { TypeormAdapter } from '@rapiq/adapter-typeorm';
const adapter = new TypeormAdapter({ queryBuilder, relations: { joinAndSelect: true } });
const { pagination } = adapter.execute(query);
const [entities, total] = await queryBuilder.getManyAndCount();
Values are always bound as parameters, never interpolated into the statement:
WHERE "user"."age" >= \$1 -- params: [18]
ORDER BY "user"."id" DESC
LIMIT 20
The complete Express + TypeORM endpoint
Assumes express and
typeorm are installed, with User and Realm declared as
TypeORM entities and the registry from step 2 in scope.
import { Request, Response } from 'express';
import { createURLCodec } from '@rapiq/codec-url';
import { TypeormAdapter } from '@rapiq/adapter-typeorm';
// your app's TypeORM DataSource instance
import { dataSource } from './data-source';
const codec = createURLCodec(registry);
/**
* Get many users.
*
* Request example
* - url: /users?codec=url-expression&page[limit]=10&include=realm&filter=eq(id,'1')&fields=id,name
*/
export async function getUsers(req: Request, res: Response) {
// map the URL wire names (filter, page, include, ...) to their canonical
// parameters and validate against the schema allow-lists.
const query = codec.decode(req.query, { schema: 'user' });
if (!query) {
return res.status(400).end();
}
const queryBuilder = dataSource
.getRepository(User)
.createQueryBuilder('user');
const adapter = new TypeormAdapter({
queryBuilder,
relations: { joinAndSelect: true },
});
const { pagination } = adapter.execute(query);
const [entities, total] = await queryBuilder.getManyAndCount();
return res.json({
data: entities,
meta: {
total,
limit: pagination.limit,
offset: pagination.offset,
},
});
}
No TypeORM? The same query runs through @rapiq/adapter-prisma, @rapiq/adapter-drizzle, @rapiq/adapter-sql (parameterized fragments for any driver) or @rapiq/adapter-memory (plain arrays). Full walkthrough in the docs.
Installation
Version 2 splits the former single rapiq package into focused @rapiq/* packages, so each side
installs only what it needs. There is no rapiq umbrella package for v2, and @rapiq/core is a
peer dependency of every other package.
# calling application: build queries, encode them as URL query strings
npm install @rapiq/core @rapiq/codec-url
# receiving application: decode, validate, execute
npm install @rapiq/core @rapiq/codec-url @rapiq/adapter-sql @rapiq/adapter-typeorm
Swap the adapters for @rapiq/adapter-prisma, @rapiq/adapter-drizzle or @rapiq/adapter-memory
to match your backend.
Packages
| Package | Purpose |
|---|---|
| @rapiq/core | Query AST, typed build layer (defineQuery, condition helpers, mergeQueries), schema system & registry |
| @rapiq/parser-simple | Parses plain object/array input (the "simple" dialect) into a Query |
| @rapiq/parser-expression | Parses filter expressions like and(eq(name, 'John'), gte(age, '18')) |
| @rapiq/parser-mongo | Parses MongoDB-style filter documents like { age: { $gte: 18 } } |
| @rapiq/codec-url | URL query-string codec; writes expression filters and reads expression plus legacy simple filters |
| @rapiq/adapter-sql | Dialect-agnostic SQL adapter (pg, mysql, sqlite, mssql & oracle presets) |
| @rapiq/adapter-typeorm | Applies a parsed Query to a TypeORM SelectQueryBuilder |
| @rapiq/adapter-prisma | Serializes a parsed Query into a Prisma argument object |
| @rapiq/adapter-drizzle | Serializes a parsed Query into a Drizzle relational query config |
| @rapiq/adapter-memory | Evaluates a parsed Query against in-memory objects & arrays |
Parameters
The query scheme is based on the JSON-API specification:
| Parameter | URL name | Description |
|---|---|---|
fields | fields | Return only specific resource fields or extend the default selection. |
filters | filter | Filter the resources, according to specific criteria. |
relations | include | Include related resources of the primary resource. |
pagination | page | Limit the number of resources returned from the entire collection. |
sorts | sort | Sort the resources according to one or more keys in asc/desc direction. |
License
Made with 💚
Published under MIT License.