MongoDB-Style Search Endpoint
Chapter 5 of the recipes storyline: after the previous chapter swapped the execute layer, this one swaps the input dialect; the contract from chapter 3 stays. Next: Testing with the Memory Adapter.
URL query strings suit GET list endpoints, but filters do not always travel that way: complex search forms, saved filters, or a caller that already speaks the MongoDB query language all want structured JSON. @rapiq/parser-mongo parses a MongoDB-style filter document into the same Query as every other dialect, validated against the same schema. The server does not run MongoDB; the dialect is input syntax only.
This chapter adds POST /users/search next to chapter 3's GET /users. The contract module (src/schema.ts) and the execution half are untouched; only the parse step changes.
The endpoint
// src/routes/users-search.ts
import type { Request, Response } from 'express';
import { ParseError } from '@rapiq/core';
import { MongoParser } from '@rapiq/parser-mongo';
import { TypeormAdapter } from '@rapiq/adapter-typeorm';
import { dataSource } from '../data-source';
import { registry } from '../schema';
import { User } from '../entities';
const parser = new MongoParser(registry);
export async function searchUsers(req: Request, res: Response) {
let query;
try {
query = parser.parse(req.body, { schema: 'user' });
} catch (e) {
if (e instanceof ParseError) {
return res.status(400).json({ error: e.message, code: e.code });
}
throw e;
}
const queryBuilder = dataSource.getRepository(User).createQueryBuilder('user');
const adapter = new TypeormAdapter({
relations: { joinAndSelect: true },
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,
},
});
}Wire it up with a JSON body parser: app.use(express.json()) and app.post('/users/search', searchUsers). Like the codec, the parser is stateless between calls; share one instance across routes. Unlike codec.decode, parser.parse never returns null: it returns a Query, and everything invalid throws.
What clients send
{
"filters": {
"$or": [
{ "name": { "$contains": "ada" } },
{ "age": { "$gte": 18, "$lt": 65 } }
],
"realm.id": "a"
},
"relations": ["realm"],
"sort": "-age",
"pagination": { "limit": 25 }
}Multiple document entries combine with an implicit AND, a bare scalar means $eq, a bare array means $in, and dotted keys reach relation paths; the full operator table lives on the package page.
Only filters is mongo-flavored. fields, relations, sort and pagination accept the same shapes as the simple dialect from chapter 3, and every schema constraint applies unchanged: the filter allow-list, the relations allow-list, the maxLimit of 50, the -id sort default when the client sends none.
Typed values, no wire grammar
The URL dialects are untyped; every value crosses the wire as a string and the wire grammar coerces it back. A JSON body carries real types, so nothing is coerced:
filter[age]=>=18 URL simple: a string, operator and value share one token
filter=gte(age,'18') URL expression: value quoted as a string, coerced on decode
{ "age": { "$gte": 18 } } JSON body: a real number reaches the AST untouchedThat buys three things the URL dialects cannot express:
- Numbers and booleans stay themselves.
18is a number,trueis a boolean; there is no string-form ambiguity. nullis a real null.{ "field": null }is an is-null test (see null semantics), never the string'null'.- Operators without a URL grammar become reachable.
$regex(as a pattern string, optionally with$options),$mod,$size,$elemMatchand$allhave no expression spelling but parse fine from a document. When callingparse()in-process,DateandRegExpinstances pass through as-is; over JSON, an ISO date string stays a string and is not coerced to aDate.
Untrusted $regex
This is the only dialect that lets a client submit a regular expression. Here the pattern is handed to the database engine; if the same queries are ever evaluated in-process with @rapiq/adapter-memory (next chapter), a crafted pattern can burn CPU. Gate the operator with the schema's filters.validate hook: see the regex trust model.
Grammar errors vs. contract violations
The parser splits failures into two classes:
- Grammar errors always throw
FiltersParseError, no matter what the schema's failure policy says: unknown or misplaced$-operators, malformed operator values, invalid compound arrays. A$-prefixed key is never a field name, so a broken document has no silent-drop reading. - Allow-list failures follow the schema policy. This contract sets
throwOnFailure: true, so an undeclared field key throws too; without it, the offending entry (an$elemMatchsubtree included) would be dropped silently and the schema defaults would fill the gaps.
{ "filters": { "age": { "$type": "number" } } } 400, code operatorUnsupported (grammar, throws regardless)
{ "filters": { "secret": "x" } } 400, code keyNotAllowed (contract, throws via throwOnFailure)FiltersParseError extends ParseError, so the handler's single catch branch maps both classes to a 400. See the failure model for the full error-code table.
Variations
- Per-actor gating: schemas with async
validatehooks needparser.parseAsync(req.body, { schema: 'user', context: req.actor }); the hooks and context are the subject of Authorization & Scoping. - Different backend: swap the execute half exactly as chapter 4 did for Prisma & Drizzle; the parse half stays identical.
- Filters only: the standalone
MongoFiltersParserreturns just theFiltersnode, useful when a stored filter document must be revalidated without the other parameters.