Skip to content

REST API with Express & TypeORM

Chapter 3 of the recipes storyline: the server baseline of the realm/user API, receiving what the frontend chapter sends. Next: swapping the backend for Prisma or Drizzle.

The complete, production-shaped list endpoint: schemas in one module, a decoder shared across handlers, per-request adapters, a meta block in the response and clean 400s for contract violations.

Project layout

txt
src/
├── schema.ts        # schemas + registry (one place for the whole contract)
├── codec.ts         # shared URL codec
└── routes/users.ts  # the endpoint

1. The contract

typescript
// src/schema.ts
import { SchemaRegistry, defineSchema } from '@rapiq/core';
import type { Realm, User } from './entities';

export const registry = new SchemaRegistry();

registry.add(defineSchema<Realm>({
    name: 'realm',
    fields: { allowed: ['id', 'name'] },
    filters: { allowed: ['id', 'name'] },
}));

registry.add(defineSchema<User>({
    name: 'user',
    fields: {
        allowed: ['id', 'name', 'email', 'age'],
        default: ['id', 'name'],
    },
    filters: { allowed: ['id', 'name', 'age', 'realm.id'] },
    relations: { allowed: ['realm'] },
    sorts: {
        allowed: ['id', 'name', 'age'],
        default: { id: 'DESC' },
    },
    pagination: { maxLimit: 50 },
    schemaMapping: { realm: 'realm' },
    throwOnFailure: true,   // contract violations become 400s instead of silent drops
}));

throwOnFailure controls fields, relations, sorts, pagination and legacy simple filters. Expression filters already reject contract violations precisely. See drop vs. throw.

2. The codec

typescript
// src/codec.ts
import { createURLCodec } from '@rapiq/codec-url';
import { registry } from './schema';

export const codec = createURLCodec(registry);

The codec is stateless between calls; share one instance across all routes.

3. The endpoint

typescript
// src/routes/users.ts
import type { Request, Response } from 'express';
import { ParseError } from '@rapiq/core';
import { TypeormAdapter } from '@rapiq/adapter-typeorm';
import { dataSource } from '../data-source';
import { codec } from '../codec';
import { User } from '../entities';

const adapterConfig = { relations: { joinAndSelect: true } };

export async function getUsers(req: Request, res: Response) {
    let query;
    try {
        query = codec.decode(req.query, { schema: 'user' });
    } catch (e) {
        if (e instanceof ParseError) {
            return res.status(400).json({ error: e.message, code: e.code });
        }
        throw e;
    }
    if (!query) {
        return res.status(400).json({ error: 'Invalid query input.' });
    }

    const queryBuilder = dataSource.getRepository(User).createQueryBuilder('user');

    // adapter & builder are per-request; the config object is shared
    const adapter = new TypeormAdapter({ ...adapterConfig, queryBuilder });
    const { pagination } = adapter.execute(query);

    const [entities, total] = await queryBuilder.getManyAndCount();

    return res.json({
        data: entities,
        meta: {
            total,
            limit: pagination.limit,
            offset: pagination.offset,
        },
    });
}

What clients can now do

txt
GET /users                                            defaults: id+name, sorted -id, limit 50
GET /users?filter[age]=>=18&sort=-age                 filtered & sorted
GET /users?include=realm&fields[realm]=name           relation + sparse fields
GET /users?page[limit]=10&page[offset]=20             paged; a limit above 50 is a 400
GET /users?filter[secret]=x                           400: key not allowed

Variations

Released under the MIT License.