README.md

August 12, 2026 · View on GitHub

rapiq

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.

CI codecov Known Vulnerabilities Conventional Commits License: MIT

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 Schema declares 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 Prisma findMany args object, a drizzle relational-queries config, or compiled functions over in-memory data.
  • 📦 Composable packages: no monolith, install only what each side needs; @rapiq/core is 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

PackagePurpose
@rapiq/coreQuery AST, typed build layer (defineQuery, condition helpers, mergeQueries), schema system & registry
@rapiq/parser-simpleParses plain object/array input (the "simple" dialect) into a Query
@rapiq/parser-expressionParses filter expressions like and(eq(name, 'John'), gte(age, '18'))
@rapiq/parser-mongoParses MongoDB-style filter documents like { age: { $gte: 18 } }
@rapiq/codec-urlURL query-string codec; writes expression filters and reads expression plus legacy simple filters
@rapiq/adapter-sqlDialect-agnostic SQL adapter (pg, mysql, sqlite, mssql & oracle presets)
@rapiq/adapter-typeormApplies a parsed Query to a TypeORM SelectQueryBuilder
@rapiq/adapter-prismaSerializes a parsed Query into a Prisma argument object
@rapiq/adapter-drizzleSerializes a parsed Query into a Drizzle relational query config
@rapiq/adapter-memoryEvaluates a parsed Query against in-memory objects & arrays

Parameters

The query scheme is based on the JSON-API specification:

ParameterURL nameDescription
fieldsfieldsReturn only specific resource fields or extend the default selection.
filtersfilterFilter the resources, according to specific criteria.
relationsincludeInclude related resources of the primary resource.
paginationpageLimit the number of resources returned from the entire collection.
sortssortSort the resources according to one or more keys in asc/desc direction.

License

Made with 💚

Published under MIT License.