diff --git a/.agents/architecture.md b/.agents/architecture.md index a2322f483..421097033 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -31,7 +31,14 @@ The `Query` AST is an **intermediate representation (IR)**. Every package plays 1. **Define & interact** — client-side construction (plan 012): `defineQuery(QueryBuildInput)` + per-parameter `define*` fragment factories desugar typed input (scalars → `eq`, bare arrays → `in` with `null` legal, `$`-operator objects, condition-helper trees) straight to the AST — schema-free, no parsing. Condition helpers (`parameter/filters/helpers/`, one per `FilterFieldOperator`; `in` → `inArray` since `in` is reserved) build `Filter`/`Filters` nodes directly. Queries compose immutably via `mergeQueries` (left-priority; fields/relations/sorts keyed by name, pagination per-property) and the `Filters` combinators: `merge()` = per-field replace, flat root-AND only (typed `MergeError`, `ErrorCode.FILTERS_NOT_FLAT`); `and()`/`or()` = wrap & inject (server scoping — injected conditions can't be displaced by later merges). `$and`/`$or` object keys stay reserved for a future mongo parser dialect. `QueryBuilder` was removed — `defineQuery` replaces it. 2. **Parse to IR** — parsers transform *dialect* input (a spec for how parameters are written: "simple" object shapes, "expression" strings) into the IR, validated against a `Schema`. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. 3. **Consume the IR** — either interpret/walk it directly (`@rapiq/sql`, `@rapiq/typeorm` via visitors), or… -4. **Transport the IR between application boundaries via a codec** — `@rapiq/codec-url-simple` is *one* such codec (HTTP URI scheme). The codec owns the complete wire format: the parameter wire names (`URLParameter`: `filter`, `page`, `include`, …) live **only** there, and `URLDecoder` is the boundary adapter — it accepts a raw query string *or* a pre-parsed query object (express `req.query`), maps wire names to canonical parameters and delegates to a schema-aware `SimpleParser`. App2 then works with the same IR. +4. **Transport the IR between application boundaries via a codec** — `@rapiq/codec-url-simple` is *one* such codec (HTTP URI scheme). The codec owns the complete wire format: the parameter wire names (`URLParameter`: `filter`, `page`, `include`, …) live **only** there, and `URLDecoder` is the boundary adapter — it accepts a raw query string *or* a pre-parsed query object (express `req.query`), maps wire names to canonical parameters and delegates to a schema-aware `SimpleParser`. App2 then works with the same IR. `@rapiq/codec-url-expression` is the sibling codec for the expression dialect (nested filter compounds in a single `filter=and(...)` param; the other four parameters share the simple wire machinery). + +Codec rules settled during plan 007 (2026-07): + +- **Subset law**: each dialect expresses only a subset of the IR — within it `decode(encode(q)) ≍ q` *modulo scalar type normalization* (the wire is untyped: `'5'` → `5`, `'true'` → `true`); outside it `encode` throws typed `FEATURE_UNSUPPORTED`/`OPERATOR_UNSUPPORTED` instead of silently changing semantics. The simple encoder enforces this pointwise: every emitted wire token is re-parsed and must decode back to the operator it came from. +- **Codec identity is in-band** (reverses the earlier out-of-band-only stance): `@rapiq/codec-url` ships `URLCodecRegistry` — encoding through it stamps a reserved `codec` parameter; decoding dispatches on it (absent → default simple, so plain clients keep working; unregistered name → typed `CodecError`, never a silent mis-decode). Each codec package also exports its identifier constant for out-of-band negotiation. +- **Schema-aware encode** validates by piping the plain-encoded output through the schema-bound decoder and re-encoding — parser-exact semantics by construction (drop by default, schema `throwOnFailure` opts into throwing); parameters absent from the input query are masked so schema defaults don't materialize onto the wire. +- The shared filter-value wire grammar (`parseFilterScalar`/`parseFilterValue`/`parseFilterWireValue`/`serializeFilterValue`) lives in `@rapiq/parser-simple` (`parameter/filters/value.ts`) — the single source for scalar coercion and operator-marker parsing used by both parsers and the simple codec. Placement rules that follow (settled during plan 006, don't re-litigate): diff --git a/.agents/structure.md b/.agents/structure.md index 2b032eaf7..a20d7db2a 100644 --- a/.agents/structure.md +++ b/.agents/structure.md @@ -10,6 +10,8 @@ npm-workspaces monorepo (`packages/*`) orchestrated by Nx. Every publishable pac | [@rapiq/parser-simple](../packages/parser-simple) | Library | Parses plain object/array input (URL-query-like "simple" dialect) into a `Query` | | [@rapiq/parser-expression](../packages/parser-expression) | Library | Parses a function-call expression language (e.g. `and(eq(name, 'John'), gte(age, '18'))`) into a `Query` | | [@rapiq/codec-url-simple](../packages/codec-url-simple) | Library | URL query-string encoder (`URLEncoder`) & decoder (`URLDecoder`) for the simple dialect; uses `qs` | +| [@rapiq/codec-url-expression](../packages/codec-url-expression) | Library | URL codec for the expression dialect: nested filter compounds in a single `filter=and(...)` param; other parameters shared with codec-url-simple | +| [@rapiq/codec-url](../packages/codec-url) | Library | `URLCodecRegistry` dispatching between URL codec dialects via the in-band reserved `codec` parameter (default: simple) | | [@rapiq/sql](../packages/sql) | Library | Dialect-agnostic SQL adapter + visitor; ships dialect presets (pg, mysql, sqlite, mssql, oracle) | | [@rapiq/typeorm](../packages/typeorm) | Library | Adapter applying a parsed `Query` to a TypeORM `SelectQueryBuilder` | | [@rapiq/docs](../packages/docs) | Docs app | VitePress documentation site (rapiq.tada5hi.net); private, not published | @@ -30,6 +32,12 @@ Layer 2: @rapiq/parser-expression (core + parser-simple) @rapiq/codec-url-simple (core + parser-simple) @rapiq/typeorm (core + sql + typeorm) + +Layer 3: + @rapiq/codec-url-expression (core + parser-expression + codec-url-simple) + +Layer 4: + @rapiq/codec-url (core + codec-url-simple + codec-url-expression) ``` Changes to `@rapiq/core` affect every other package. @@ -84,6 +92,14 @@ packages/codec-url-simple/src/ ├── encoder/ # URLEncoder + serializer/ + visitors/ ├── decoder/ # URLDecoder (qs-based, reuses parser-simple parsers) └── utils/ + +packages/codec-url-expression/src/ +├── encoder/ # URLEncoder (filters → expression string; other params via codec-url-simple) +└── decoder/ # URLDecoder (qs-based, delegates to ExpressionParser) + +packages/codec-url/src/ +├── module.ts # URLCodecRegistry (in-band `codec` param dispatch) +└── factory.ts # createURLCodecRegistry (bundles simple + expression) ``` ## Package Exports @@ -109,6 +125,6 @@ Public API is controlled via the barrel `src/index.ts` of each package; anything - **AST & type definitions** → `@rapiq/core` (`parameter/`) - **What a client may request (allow-lists, defaults, mappings)** → `@rapiq/core` (`schema/`) -- **Turning raw input into the AST** → `@rapiq/parser-simple`, `@rapiq/parser-expression`, `@rapiq/codec-url-simple` (decode) -- **Turning the AST into transport format** → `@rapiq/codec-url-simple` (encode) +- **Turning raw input into the AST** → `@rapiq/parser-simple`, `@rapiq/parser-expression`, `@rapiq/codec-url-{simple,expression}` (decode) +- **Turning the AST into transport format** → `@rapiq/codec-url-{simple,expression}` (encode), `@rapiq/codec-url` (dialect dispatch via in-band `codec` param) - **Turning the AST into backend queries** → `@rapiq/sql`, `@rapiq/typeorm` diff --git a/package-lock.json b/package-lock.json index 57a3d0b57..559c59757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1755,6 +1755,14 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/@rapiq/codec-url": { + "resolved": "packages/codec-url", + "link": true + }, + "node_modules/@rapiq/codec-url-expression": { + "resolved": "packages/codec-url-expression", + "link": true + }, "node_modules/@rapiq/codec-url-simple": { "resolved": "packages/codec-url-simple", "link": true @@ -11615,6 +11623,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/codec-url": { + "name": "@rapiq/codec-url", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "qs": "^6.15.3" + }, + "devDependencies": { + "@rapiq/codec-url-expression": "^1.0.0", + "@rapiq/codec-url-simple": "^1.0.0", + "@rapiq/core": "^1.0.0", + "@types/qs": "^6.14.0" + }, + "peerDependencies": { + "@rapiq/codec-url-expression": "^1.0.0", + "@rapiq/codec-url-simple": "^1.0.0", + "@rapiq/core": "^1.0.0" + } + }, + "packages/codec-url-expression": { + "name": "@rapiq/codec-url-expression", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "qs": "^6.15.3" + }, + "devDependencies": { + "@rapiq/codec-url-simple": "^1.0.0", + "@rapiq/core": "^1.0.0", + "@rapiq/parser-expression": "^1.0.0", + "@rapiq/parser-simple": "^1.0.0", + "@types/qs": "^6.14.0" + }, + "peerDependencies": { + "@rapiq/codec-url-simple": "^1.0.0", + "@rapiq/core": "^1.0.0", + "@rapiq/parser-expression": "^1.0.0", + "@rapiq/parser-simple": "^1.0.0" + } + }, "packages/codec-url-simple": { "name": "@rapiq/codec-url-simple", "version": "1.0.0", diff --git a/packages/codec-url-expression/package.json b/packages/codec-url-expression/package.json new file mode 100644 index 000000000..21614dda6 --- /dev/null +++ b/packages/codec-url-expression/package.json @@ -0,0 +1,72 @@ +{ + "name": "@rapiq/codec-url-expression", + "version": "1.0.0", + "description": "A package containing an url encoder & decoder for the expression dialect.", + "type": "module", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist/" + ], + "dependencies": { + "qs": "^6.15.3" + }, + "devDependencies": { + "@rapiq/codec-url-simple": "^1.0.0", + "@rapiq/core": "^1.0.0", + "@rapiq/parser-expression": "^1.0.0", + "@rapiq/parser-simple": "^1.0.0", + "@types/qs": "^6.14.0" + }, + "peerDependencies": { + "@rapiq/codec-url-simple": "^1.0.0", + "@rapiq/core": "^1.0.0", + "@rapiq/parser-expression": "^1.0.0", + "@rapiq/parser-simple": "^1.0.0" + }, + "scripts": { + "build:types": "tsc --noEmit -p tsconfig.build.json", + "build:js": "tsdown", + "build": "npm run build:types && npm run build:js", + "test": "vitest --config test/vitest.config.ts --run", + "test:coverage": "vitest --config test/vitest.config.ts --run --coverage", + "prepublishOnly": "npm run build" + }, + "author": { + "name": "Peter Placzek", + "email": "contact@tada5hi.net", + "url": "https://github.com/tada5hi" + }, + "license": "MIT", + "keywords": [ + "query", + "json", + "json-api", + "api", + "rest", + "api-utils", + "include", + "pagination", + "sort", + "fields", + "filter", + "relations", + "typescript" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Tada5hi/rapiq.git", + "directory": "packages/codec-url-expression" + }, + "bugs": { + "url": "https://github.com/Tada5hi/rapiq/issues" + }, + "homepage": "https://github.com/Tada5hi/rapiq#readme" +} diff --git a/packages/codec-url-expression/src/constants.ts b/packages/codec-url-expression/src/constants.ts new file mode 100644 index 000000000..98aa6da63 --- /dev/null +++ b/packages/codec-url-expression/src/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +/** + * Stable identifier of this codec (wire dialect), e.g. for the + * in-band `codec` parameter dispatched by a codec registry or an + * out-of-band content-negotiation header. + */ +export const URL_EXPRESSION_CODEC = 'url-expression'; diff --git a/packages/codec-url-expression/src/decoder/index.ts b/packages/codec-url-expression/src/decoder/index.ts new file mode 100644 index 000000000..9c0e61a48 --- /dev/null +++ b/packages/codec-url-expression/src/decoder/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export * from './module'; diff --git a/packages/codec-url-expression/src/decoder/module.ts b/packages/codec-url-expression/src/decoder/module.ts new file mode 100644 index 000000000..383bf6d16 --- /dev/null +++ b/packages/codec-url-expression/src/decoder/module.ts @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + ExpressionFieldsParser, + ExpressionFiltersParser, + ExpressionPaginationParser, + ExpressionParser, + ExpressionRelationsParser, + ExpressionSortParser, +} from '@rapiq/parser-expression'; +import { URLParameter } from '@rapiq/codec-url-simple'; +import { parse } from 'qs'; +import type { + IFields, + IFilters, + IPagination, + IQuery, + IRelations, + ISorts, + ObjectLiteral, + ParseParameterOptions, + ParseQueryOptions, + SchemaRegistry, +} from '@rapiq/core'; +import { + Parameter, + isObject, + isPropertySet, +} from '@rapiq/core'; + +export class URLDecoder { + protected parser : ExpressionParser; + + protected fields: ExpressionFieldsParser; + + protected filters : ExpressionFiltersParser; + + protected pagination : ExpressionPaginationParser; + + protected relations: ExpressionRelationsParser; + + protected sort: ExpressionSortParser; + + constructor(input?: SchemaRegistry) { + this.parser = new ExpressionParser(input); + + this.fields = new ExpressionFieldsParser(input); + this.filters = new ExpressionFiltersParser(input); + this.pagination = new ExpressionPaginationParser(input); + this.relations = new ExpressionRelationsParser(input); + this.sort = new ExpressionSortParser(input); + } + + /** + * Decode a query string or an already parsed query object + * (e.g. an express req.query): the URL wire names are mapped + * to their canonical parameters and parsed to a query. The + * filter parameter carries a single expression string + * (e.g. filter=and(eq(name,'John'),gte(age,'18'))). + * + * @param input + * @param options + */ + decode( + input: string | ObjectLiteral, + options: ParseQueryOptions = {}, + ) : IQuery | null { + const parsed = typeof input === 'string' ? parse(input) : input; + if (!isObject(parsed)) { + return null; + } + + const mapped : ObjectLiteral = {}; + + this.mapParameter(parsed, mapped, URLParameter.FIELDS, Parameter.FIELDS); + this.mapParameter(parsed, mapped, URLParameter.FILTERS, Parameter.FILTERS); + this.mapParameter(parsed, mapped, URLParameter.PAGINATION, Parameter.PAGINATION); + this.mapParameter(parsed, mapped, URLParameter.RELATIONS, Parameter.RELATIONS); + this.mapParameter(parsed, mapped, URLParameter.SORT, Parameter.SORT); + + return this.parser.parse(mapped, options); + } + + decodeFields( + input: string, + options: ParseParameterOptions = {}, + ) : IFields | null { + const output = parse(input); + if (!isObject(output)) { + return null; + } + + if (output[URLParameter.FIELDS]) { + return this.fields.parse(output[URLParameter.FIELDS], options); + } + + return this.fields.parse(output, options); + } + + decodeFilters( + input: string, + options: ParseParameterOptions = {}, + ) : IFilters | null { + const output = parse(input); + if (!isObject(output)) { + return null; + } + + // an empty string is NOT absent — the expression dialect is + // precise, so `filter=` must surface the parser's syntax error + // instead of falling back to schema defaults. + if (isPropertySet(output, URLParameter.FILTERS)) { + return this.filters.parse(output[URLParameter.FILTERS], options); + } + + return this.filters.parse(undefined, options); + } + + decodePagination( + input: string, + options: ParseParameterOptions = {}, + ) : IPagination | null { + const output = parse(input); + if (!isObject(output)) { + return null; + } + + if (output[URLParameter.PAGINATION]) { + return this.pagination.parse(output[URLParameter.PAGINATION], options); + } + + return this.pagination.parse(output, options); + } + + decodeRelations( + input: string, + options: ParseParameterOptions = {}, + ) : IRelations | null { + const output = parse(input); + if (!isObject(output)) { + return null; + } + + if (output[URLParameter.RELATIONS]) { + return this.relations.parse(output[URLParameter.RELATIONS], options); + } + + return this.relations.parse(output, options); + } + + decodeSort( + input: string, + options: ParseParameterOptions = {}, + ) : ISorts | null { + const output = parse(input); + if (!isObject(output)) { + return null; + } + + if (output[URLParameter.SORT]) { + return this.sort.parse(output[URLParameter.SORT], options); + } + + return this.sort.parse(output, options); + } + + // -------------------------------------------------- + + protected mapParameter( + input: ObjectLiteral, + output: ObjectLiteral, + urlKey: string, + key: `${Parameter}`, + ) : void { + if (isPropertySet(input, urlKey)) { + output[key] = input[urlKey]; + } + } +} diff --git a/packages/codec-url-expression/src/encoder/filters.ts b/packages/codec-url-expression/src/encoder/filters.ts new file mode 100644 index 000000000..665b4b86a --- /dev/null +++ b/packages/codec-url-expression/src/encoder/filters.ts @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import type { ICondition, IFilters } from '@rapiq/core'; +import { + AdapterError, + Filter, + FilterFieldOperator, + Filters, +} from '@rapiq/core'; +import { parseFilterScalar } from '@rapiq/parser-simple'; + +/** + * Grammar keywords of the expression dialect — a field segment + * matching one of these would tokenize as an operator on decode. + */ +const KEYWORDS = new Set([ + 'not', + 'and', + 'or', + 'eq', + 'gt', + 'gte', + 'lt', + 'lte', + 'contains', + 'startsWith', + 'endsWith', + 'in', + 'nin', + 'null', +]); + +/** + * A field segment must survive the expression tokenizer unchanged. + */ +const FIELD_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?$/; + +/** + * Serialize a filters tree to its expression-dialect wire form, + * e.g. and(eq(name,'John'),or(gte(age,'18'),eq(email,null))). + * Nested compounds are first-class in this dialect; only operators + * without a grammar production (REGEX, MOD, EXISTS, ELEM_MATCH) + * and non-wire-safe names/values are rejected. + * + * Returns null for an empty root (nothing to emit). + */ +export function serializeFiltersExpression(input: IFilters) : string | null { + if (input.value.length === 0) { + return null; + } + + // a single root condition needs no compound wrapper — the parser + // wraps bare conditions back into a root AND. + if ( + input.value.length === 1 && + input.value[0] instanceof Filter + ) { + return serializeCondition(input.value[0]); + } + + return serializeCompound(input); +} + +function serializeCompound(input: IFilters) : string { + if (input.value.length === 0) { + // an empty compound has no grammar production — and() is a + // syntax error on decode. + throw AdapterError.featureUnsupported('filters:compound:empty'); + } + + const children = input.value.map( + (child) => serializeCondition(child), + ); + + return `${input.operator}(${children.join(',')})`; +} + +function serializeCondition(node: ICondition) : string { + if (node instanceof Filters) { + return serializeCompound(node); + } + + if (!(node instanceof Filter)) { + throw AdapterError.featureUnsupported('filters:condition'); + } + + const field = serializeField(node.field); + + switch (node.operator) { + case FilterFieldOperator.EQUAL: { + return `eq(${field},${serializeValue(node.value)})`; + } + case FilterFieldOperator.NOT_EQUAL: { + return `not(eq(${field},${serializeValue(node.value)}))`; + } + case FilterFieldOperator.LESS_THAN: { + return `lt(${field},${serializeValue(node.value)})`; + } + case FilterFieldOperator.LESS_THAN_EQUAL: { + return `lte(${field},${serializeValue(node.value)})`; + } + case FilterFieldOperator.GREATER_THAN: { + return `gt(${field},${serializeValue(node.value)})`; + } + case FilterFieldOperator.GREATER_THAN_EQUAL: { + return `gte(${field},${serializeValue(node.value)})`; + } + case FilterFieldOperator.IN: { + return `in(${field}${serializeList(node.value)})`; + } + case FilterFieldOperator.NOT_IN: { + return `nin(${field}${serializeList(node.value)})`; + } + case FilterFieldOperator.CONTAINS: { + return `contains(${field},${serializeMatchText(node.value)})`; + } + case FilterFieldOperator.NOT_CONTAINS: { + return `not(contains(${field},${serializeMatchText(node.value)}))`; + } + case FilterFieldOperator.STARTS_WITH: { + return `startsWith(${field},${serializeMatchText(node.value)})`; + } + case FilterFieldOperator.NOT_STARTS_WITH: { + return `not(startsWith(${field},${serializeMatchText(node.value)}))`; + } + case FilterFieldOperator.ENDS_WITH: { + return `endsWith(${field},${serializeMatchText(node.value)})`; + } + case FilterFieldOperator.NOT_ENDS_WITH: { + return `not(endsWith(${field},${serializeMatchText(node.value)}))`; + } + default: { + // REGEX, MOD, EXISTS, ELEM_MATCH, ... have no expression + // grammar production. + throw AdapterError.operatorUnsupported(node.operator); + } + } +} + +function serializeField(input: string) : string { + const segments = input.split('.'); + + for (const segment of segments) { + if ( + !FIELD_SEGMENT.test(segment) || + KEYWORDS.has(segment) + ) { + throw AdapterError.featureUnsupported(`filters:field:${input}`); + } + } + + return input; +} + +function serializeValue(input: unknown) : string { + if ( + typeof input === 'undefined' || + input === null + ) { + return 'null'; + } + + if (typeof input === 'string') { + return `'${input.replace(/'/g, '\'\'')}'`; + } + + if ( + typeof input === 'number' || + typeof input === 'boolean' + ) { + // quoted; the decoder coerces the scalar type back. + return `'${input}'`; + } + + throw AdapterError.featureUnsupported('filters:value:type'); +} + +function serializeList(input: unknown) : string { + const value = Array.isArray(input) ? input : [input]; + + return value + .map((element) => `,${serializeValue(element)}`) + .join(''); +} + +/** + * Match text (contains/startsWith/endsWith) must stay a string + * after the decoder's scalar coercion — numeric- or boolean-looking + * text ('5', 'true') would decode to a non-string and fail there. + */ +function serializeMatchText(input: unknown) : string { + const text = typeof input === 'string' ? input : `${input}`; + + if (typeof parseFilterScalar(text) !== 'string') { + throw AdapterError.featureUnsupported('filters:value:text'); + } + + return serializeValue(text); +} diff --git a/packages/codec-url-expression/src/encoder/index.ts b/packages/codec-url-expression/src/encoder/index.ts new file mode 100644 index 000000000..f4a39d01a --- /dev/null +++ b/packages/codec-url-expression/src/encoder/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export * from './filters'; +export * from './module'; diff --git a/packages/codec-url-expression/src/encoder/module.ts b/packages/codec-url-expression/src/encoder/module.ts new file mode 100644 index 000000000..ef7ad5fe9 --- /dev/null +++ b/packages/codec-url-expression/src/encoder/module.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import type { + IFields, + IFilters, + IPagination, + IQuery, + IRelations, + ISorts, + ParseParameterOptions, + ParseQueryOptions, + SchemaRegistry, +} from '@rapiq/core'; +import { + URLEncoder as SimpleURLEncoder, + URLParameter, +} from '@rapiq/codec-url-simple'; +import { URLDecoder } from '../decoder'; +import { serializeFiltersExpression } from './filters'; + +type QueryParameterMask = { + fields?: boolean, + filters?: boolean, + pagination?: boolean, + relations?: boolean, + sorts?: boolean, +}; + +/** + * URL encoder for the expression dialect: the filter parameter + * carries a single function-call expression + * (filter=and(eq(name,'John'),or(...))) — nested compounds are + * first-class. The other four parameters share the simple + * dialect's wire format. + */ +export class URLEncoder { + protected simple : SimpleURLEncoder; + + protected decoder : URLDecoder; + + constructor(input?: SchemaRegistry) { + this.simple = new SimpleURLEncoder(input); + this.decoder = new URLDecoder(input); + } + + /** + * Encode a query to its wire format. When a schema (or strict + * mode) is provided, the emitted output is validated the same + * way the server-side decoder would treat it (see the simple + * codec — identical semantics, expression filter syntax). + * + * @param input + * @param options + */ + encode(input: IQuery, options: ParseQueryOptions = {}): string | null { + const encoded = this.encodeParts(input); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = this.decoder.decode(encoded, options); + if (!decoded) { + return null; + } + + // re-emit only parameters present in the input — validation + // must not materialize schema defaults for absent ones. + return this.encodeParts(decoded, { + fields: input.fields.value.length > 0, + filters: input.filters.value.length > 0, + pagination: typeof input.pagination.limit !== 'undefined' || + typeof input.pagination.offset !== 'undefined', + relations: input.relations.value.length > 0, + sorts: input.sorts.value.length > 0, + }); + } + + encodeFields(input: IFields, options: ParseParameterOptions = {}) { + return this.simple.encodeFields(input, options); + } + + encodeFilters(input: IFilters, options: ParseParameterOptions = {}) : string | null { + const encoded = this.serializeFilters(input); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = this.decoder.decodeFilters(encoded, options); + if (!decoded) { + return null; + } + + return this.serializeFilters(decoded); + } + + encodePagination(input: IPagination, options: ParseParameterOptions = {}) { + return this.simple.encodePagination(input, options); + } + + encodeRelations(input: IRelations, options: ParseParameterOptions = {}) { + return this.simple.encodeRelations(input, options); + } + + encodeSort(input: ISorts, options: ParseParameterOptions = {}) { + return this.simple.encodeSort(input, options); + } + + // -------------------------------------------------- + + protected encodeParts(query: IQuery, parameters?: QueryParameterMask) : string | null { + const parts = [ + (!parameters || parameters.fields) ? + this.simple.encodeFields(query.fields) : + null, + (!parameters || parameters.filters) ? + this.serializeFilters(query.filters) : + null, + (!parameters || parameters.pagination) ? + this.simple.encodePagination(query.pagination) : + null, + (!parameters || parameters.relations) ? + this.simple.encodeRelations(query.relations) : + null, + (!parameters || parameters.sorts) ? + this.simple.encodeSort(query.sorts) : + null, + ].filter(Boolean); + + if (parts.length === 0) { + return null; + } + + return parts.join('&'); + } + + protected serializeFilters(input: IFilters) : string | null { + const expression = serializeFiltersExpression(input); + if (expression === null) { + return null; + } + + return `${URLParameter.FILTERS}=${encodeURIComponent(expression)}`; + } + + protected isSchemaAware(options: ParseQueryOptions | ParseParameterOptions) : boolean { + return typeof options.schema !== 'undefined' || + typeof options.strict !== 'undefined'; + } +} diff --git a/packages/codec-url-expression/src/index.ts b/packages/codec-url-expression/src/index.ts new file mode 100644 index 000000000..fe7f426ea --- /dev/null +++ b/packages/codec-url-expression/src/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export * from './constants'; +export * from './decoder'; +export * from './encoder'; diff --git a/packages/codec-url-expression/test/data/schema.ts b/packages/codec-url-expression/test/data/schema.ts new file mode 100644 index 000000000..5eb333a36 --- /dev/null +++ b/packages/codec-url-expression/test/data/schema.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { SchemaRegistry, defineSchema } from '@rapiq/core'; +import type { Item, Realm, User } from './type'; + +export const userSchema = defineSchema({ + name: 'user', + fields: { allowed: ['id', 'name', 'email', 'age'] }, + filters: { allowed: ['id', 'name', 'email'] }, + relations: { + allowed: ['realm', 'items'], + mapping: { abc: 'items' }, + }, + sort: { allowed: ['id', 'name', 'email'] }, + schemaMapping: { items: 'item' }, +}); + +export const itemSchema = defineSchema({ + name: 'item', + fields: { allowed: ['id'] }, + filters: { allowed: ['id', 'name'] }, + relations: { allowed: ['user', 'realm'] }, + sort: { allowed: ['id'] }, +}); + +export const realmSchema = defineSchema({ + name: 'realm', + fields: { allowed: ['id', 'name', 'description'] }, + filters: { allowed: ['id', 'name'] }, + sort: { allowed: ['id', 'name'] }, +}); + +export const registry = new SchemaRegistry(); +registry.add(userSchema); +registry.add(itemSchema); +registry.add(realmSchema); diff --git a/packages/codec-url-expression/test/data/type.ts b/packages/codec-url-expression/test/data/type.ts new file mode 100644 index 000000000..212c9e34c --- /dev/null +++ b/packages/codec-url-expression/test/data/type.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export type Realm = { + id: number, + name: string, + description: string, +}; + +export type Item = { + id: string, + name: string, + realm: Realm, + user: User +}; + +export type User = { + id: string, + name: string, + email: string, + age: number, + realm: Realm, + items: Item[] +}; diff --git a/packages/codec-url-expression/test/unit/encoder-schema.spec.ts b/packages/codec-url-expression/test/unit/encoder-schema.spec.ts new file mode 100644 index 000000000..ef06b3d34 --- /dev/null +++ b/packages/codec-url-expression/test/unit/encoder-schema.spec.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + FiltersParseError, + defineQuery, + eq, + gte, + or, +} from '@rapiq/core'; +import { URLEncoder } from '../../src'; +import { registry } from '../data/schema'; + +describe('encoder (schema-aware)', () => { + let encoder : URLEncoder; + + beforeAll(() => { + encoder = new URLEncoder(registry); + }); + + it('should validate filters against the schema', () => { + const query = defineQuery({ + filters: or(eq('name', 'John'), gte('id', 5)), + sort: ['-id', 'secret'], + relations: ['abc', 'passwords'], + }); + + const encoded = encoder.encode(query, { schema: 'user' }); + + expect(decodeURIComponent(encoded!)).toEqual( + 'filter=or(eq(name,\'John\'),gte(id,\'5\'))&include=items&sort=-id', + ); + }); + + it('should throw for a disallowed filter key (the expression dialect is precise)', () => { + const query = defineQuery({ filters: eq('secret', 'x') }); + + expect(() => encoder.encode(query, { schema: 'user' })).toThrowError( + FiltersParseError, + ); + }); + + it('should resolve named schemas in per-parameter encodes', () => { + const query = defineQuery({ sort: ['-id', 'secret'] }); + + const encoded = encoder.encodeSort(query.sorts, { schema: 'user' }); + + expect(decodeURIComponent(encoded!)).toEqual('sort=-id'); + }); +}); diff --git a/packages/codec-url-expression/test/unit/roundtrip.spec.ts b/packages/codec-url-expression/test/unit/roundtrip.spec.ts new file mode 100644 index 000000000..f4be154ca --- /dev/null +++ b/packages/codec-url-expression/test/unit/roundtrip.spec.ts @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + AdapterError, + ErrorCode, + Field, + Fields, + FilterCompoundOperator, + Filters, + Pagination, + Relation, + Relations, + Sort, + SortDirection, + Sorts, + and, + contains, + defineQuery, + endsWith, + eq, + exists, + gt, + gte, + inArray, + lt, + lte, + mod, + ne, + nin, + notContains, + notEndsWith, + notStartsWith, + or, + regex, + startsWith, +} from '@rapiq/core'; +import type { IFilter, IFilters, IQuery } from '@rapiq/core'; +import { URLDecoder, URLEncoder } from '../../src'; + +/** + * The expression dialect's expressible subset is wider than the + * simple dialect's: nested and/or compounds, comma-containing + * strings and same-field conditions all round-trip. The law stays + * the same: decode(encode(q)) ≍ q modulo scalar type normalization, + * loud typed failure outside the subset. + */ +describe('round-trip', () => { + const encoder = new URLEncoder(); + const decoder = new URLDecoder(); + + const roundTrip = (query: IQuery) : IQuery => { + const encoded = encoder.encode(query); + expect(encoded).toBeTypeOf('string'); + + const decoded = decoder.decode(encoded!); + expect(decoded).toBeDefined(); + + return decoded!; + }; + + const roundTripFilter = (filter: IFilter | IFilters) : unknown => roundTrip( + defineQuery({ filters: filter }), + ).filters; + + describe('filters (operator matrix)', () => { + it.each([ + ['eq string', eq('name', 'John')], + ['eq number', eq('age', 18)], + ['eq zero', eq('age', 0)], + ['eq negative number', eq('age', -1)], + ['eq true', eq('flag', true)], + ['eq false', eq('flag', false)], + ['eq null', eq('email', null)], + ['eq comma string', eq('name', 'a,b')], + ['eq quoted text', eq('name', 'it\'s')], + ['ne string', ne('name', 'John')], + ['ne null', ne('email', null)], + ['lt', lt('age', 65)], + ['lte', lte('age', 65)], + ['gt', gt('age', 18)], + ['gte', gte('age', 18)], + ['in numbers', inArray('id', [1, 2, 3])], + ['in with null element', inArray('realm_id', ['master', null])], + ['in with comma element', inArray('name', ['a,b', 'c'])], + ['in with boolean elements', inArray('flag', [true, false])], + ['nin', nin('id', [1, 2])], + ['startsWith', startsWith('name', 'Jo')], + ['notStartsWith', notStartsWith('name', 'Jo')], + ['endsWith', endsWith('name', 'hn')], + ['notEndsWith', notEndsWith('name', 'hn')], + ['contains', contains('name', 'oh')], + ['notContains', notContains('name', 'oh')], + ['contains with comma', contains('name', 'a,b')], + ['relation path condition', eq('items.name', 'a')], + ])('should round-trip %s', (_, filter) => { + expect(roundTripFilter(filter)).toEqual( + new Filters(FilterCompoundOperator.AND, [filter]), + ); + }); + + it.each([ + [ + 'flat root and', + and(eq('name', 'John'), gte('age', 18)), + ], + [ + 'root or', + or(gte('age', 65), eq('status', 'retired')), + ], + [ + 'nested compound', + and( + eq('realm_id', 'master'), + or(gte('age', 18), eq('email', null)), + ), + ], + [ + 'same-field branches', + and(gte('age', 18), lt('age', 65)), + ], + ])('should round-trip %s', (_, filters) => { + expect(roundTripFilter(filters)).toEqual(filters); + }); + + it.each([ + [ + 'eq numeric string normalizes to number', + eq('code', '5'), + eq('code', 5), + ], + [ + 'eq boolean-like string normalizes to boolean', + eq('flag', 'true'), + eq('flag', true), + ], + ])('should round-trip %s', (_, filter, expected) => { + expect(roundTripFilter(filter)).toEqual( + new Filters(FilterCompoundOperator.AND, [expected]), + ); + }); + }); + + describe('filters (outside the dialect subset)', () => { + const expectTypedFailure = (filter: IFilter | IFilters, code: string) => { + const query = defineQuery({ filters: filter }); + + try { + encoder.encode(query); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AdapterError); + expect((e as AdapterError).code).toBe(code); + } + }; + + it.each([ + ['regex', regex('name', /^Jo/)], + ['mod', mod('age', [2, 0])], + ['exists', exists('email', true)], + ])('should throw for the %s operator (no grammar production)', (_, filter) => { + expectTypedFailure(filter, ErrorCode.OPERATOR_UNSUPPORTED); + }); + + it.each([ + ['numeric-looking match text', startsWith('code', '5')], + ['null-looking match text', contains('name', 'null')], + ['keyword field segment', eq('null', 'x')], + ['non-tokenizable field segment', eq('a b', 'x')], + ['empty nested compound', and(eq('name', 'x'), or())], + ])('should throw for %s', (_, filter) => { + expectTypedFailure(filter, ErrorCode.FEATURE_UNSUPPORTED); + }); + }); + + describe('full query', () => { + it('should round-trip every parameter', () => { + const query = defineQuery({ + fields: ['id', 'name'], + filters: or(eq('name', 'John'), gte('age', 18)), + pagination: { limit: 20, offset: 10 }, + relations: ['realm'], + sort: ['-id', 'name'], + }); + + const decoded = roundTrip(query); + + expect(decoded.fields).toEqual(new Fields([ + new Field('id'), + new Field('name'), + ])); + expect(decoded.filters).toEqual( + or(eq('name', 'John'), gte('age', 18)), + ); + expect(decoded.pagination).toEqual(new Pagination(20, 10)); + expect(decoded.relations).toEqual(new Relations([ + new Relation('realm'), + ])); + expect(decoded.sorts).toEqual(new Sorts([ + new Sort('id', SortDirection.DESC), + new Sort('name', SortDirection.ASC), + ])); + }); + + it('should surface a syntax error for an empty filter parameter', () => { + // the expression dialect is precise: `filter=` is not absent + // input and must not fall back to schema defaults. + expect(() => decoder.decodeFilters('filter=')).toThrowError(); + }); + + it('should emit the documented wire format', () => { + const query = defineQuery({ + filters: and(eq('name', 'John'), or(gte('age', 18), eq('email', null))), + pagination: { limit: 20 }, + }); + + const encoded = encoder.encode(query); + + expect(decodeURIComponent(encoded!)).toEqual( + 'filter=and(eq(name,\'John\'),or(gte(age,\'18\'),eq(email,null)))&page[limit]=20', + ); + }); + }); +}); diff --git a/packages/codec-url-expression/test/vitest.config.ts b/packages/codec-url-expression/test/vitest.config.ts new file mode 100644 index 000000000..f4afaedb4 --- /dev/null +++ b/packages/codec-url-expression/test/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/unit/**/*.{spec,test}.{ts,js}'], + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx,js,jsx}'], + exclude: ['src/**/*.d.ts'], + }, + }, +}); diff --git a/packages/codec-url-expression/tsconfig.build.json b/packages/codec-url-expression/tsconfig.build.json new file mode 100644 index 000000000..66bb87a91 --- /dev/null +++ b/packages/codec-url-expression/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"] +} diff --git a/packages/codec-url-expression/tsconfig.json b/packages/codec-url-expression/tsconfig.json new file mode 100644 index 000000000..b007e435c --- /dev/null +++ b/packages/codec-url-expression/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/codec-url-expression/tsdown.config.ts b/packages/codec-url-expression/tsdown.config.ts new file mode 100644 index 000000000..c41c74fca --- /dev/null +++ b/packages/codec-url-expression/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: 'src/index.ts', + format: 'esm', + dts: true, + sourcemap: true, + tsconfig: 'tsconfig.build.json', +}); diff --git a/packages/codec-url-simple/src/constants.ts b/packages/codec-url-simple/src/constants.ts index 1dac1ab0e..0531ff1d5 100644 --- a/packages/codec-url-simple/src/constants.ts +++ b/packages/codec-url-simple/src/constants.ts @@ -16,3 +16,10 @@ export enum URLParameter { RELATIONS = 'include', SORT = 'sort', } + +/** + * Stable identifier of this codec (wire dialect), e.g. for the + * in-band `codec` parameter dispatched by a codec registry or an + * out-of-band content-negotiation header. + */ +export const URL_SIMPLE_CODEC = 'url-simple'; diff --git a/packages/codec-url-simple/src/encoder/module.ts b/packages/codec-url-simple/src/encoder/module.ts index bf3d6ec60..d6e671b0f 100644 --- a/packages/codec-url-simple/src/encoder/module.ts +++ b/packages/codec-url-simple/src/encoder/module.ts @@ -14,7 +14,11 @@ import type { IQuery, IRelations, ISorts, + ParseParameterOptions, + ParseQueryOptions, + SchemaRegistry, } from '@rapiq/core'; +import { URLDecoder } from '../decoder'; import type { IEncoder } from '../types'; import type { ISerializer } from './serializer'; import { QueryVisitor } from './visitors'; @@ -22,42 +26,166 @@ import { QueryVisitor } from './visitors'; export class URLEncoder implements IEncoder { protected visitor : QueryVisitor; - constructor() { + protected decoder : URLDecoder; + + constructor(input?: SchemaRegistry) { this.visitor = new QueryVisitor(); + this.decoder = new URLDecoder(input); } - encode(input: IQuery): string | null { - return this.runSerializer(this.visitor.visitQuery(input)); + /** + * Encode a query to its wire format. When a schema (or strict + * mode) is provided, the emitted output is validated the same + * way the server-side decoder would treat it: disallowed keys + * are dropped, schema mappings/defaults/clamps are applied, and + * `throwOnFailure` (on the schema) opts into throwing instead — + * early client-side feedback with exact parser semantics. + * + * @param input + * @param options + */ + encode(input: IQuery, options: ParseQueryOptions = {}): string | null { + this.visitor.reset(); + + const encoded = this.runSerializer(this.visitor.visitQuery(input)); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = this.decoder.decode(encoded, options); + if (!decoded) { + return null; + } + + this.visitor.reset(); + + // re-emit only parameters present in the input — validation + // must not materialize schema defaults for absent ones. + return this.runSerializer(this.visitor.visitQuery(decoded, { + fields: input.fields.value.length > 0, + filters: input.filters.value.length > 0, + pagination: typeof input.pagination.limit !== 'undefined' || + typeof input.pagination.offset !== 'undefined', + relations: input.relations.value.length > 0, + sorts: input.sorts.value.length > 0, + })); } - encodeFields(input: IFields) { - return this.runSerializer(this.visitor.visitFields(input)); + encodeFields(input: IFields, options: ParseParameterOptions = {}) { + this.visitor.reset(); + + const encoded = this.runSerializer(this.visitor.visitFields(input)); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = this.decoder.decodeFields(encoded, options); + if (!decoded) { + return null; + } + + this.visitor.reset(); + + return this.runSerializer(this.visitor.visitFields(decoded)); } encodeField(input: IField) { + this.visitor.reset(); + return this.runSerializer(this.visitor.visitField(input)); } - encodeFilters(input: IFilters) { - return this.runSerializer(this.visitor.visitFilters(input)); + encodeFilters(input: IFilters, options: ParseParameterOptions = {}) { + this.visitor.reset(); + + const encoded = this.runSerializer(this.visitor.visitFilters(input)); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = this.decoder.decodeFilters(encoded, options); + if (!decoded) { + return null; + } + + this.visitor.reset(); + + return this.runSerializer(this.visitor.visitFilters(decoded)); } encodeFilter(input: IFilter) { + this.visitor.reset(); + return this.runSerializer(this.visitor.visitFilter(input)); } - encodePagination(input: IPagination) { - return this.runSerializer(this.visitor.visitPagination(input)); + encodePagination(input: IPagination, options: ParseParameterOptions = {}) { + this.visitor.reset(); + + const encoded = this.runSerializer(this.visitor.visitPagination(input)); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = this.decoder.decodePagination(encoded, options); + if (!decoded) { + return null; + } + + this.visitor.reset(); + + return this.runSerializer(this.visitor.visitPagination(decoded)); + } + + encodeRelations(input: IRelations, options: ParseParameterOptions = {}) { + this.visitor.reset(); + + const encoded = this.runSerializer(this.visitor.visitRelations(input)); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = this.decoder.decodeRelations(encoded, options); + if (!decoded) { + return null; + } + + this.visitor.reset(); + + return this.runSerializer(this.visitor.visitRelations(decoded)); } - encodeRelations(input: IRelations) { - return this.runSerializer(this.visitor.visitRelations(input)); + encodeSort(input: ISorts, options: ParseParameterOptions = {}) { + this.visitor.reset(); + + const encoded = this.runSerializer(this.visitor.visitSorts(input)); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = this.decoder.decodeSort(encoded, options); + if (!decoded) { + return null; + } + + this.visitor.reset(); + + return this.runSerializer(this.visitor.visitSorts(decoded)); } - encodeSort(input: ISorts) { - return this.runSerializer(this.visitor.visitSorts(input)); + /** + * The schema pass only runs on request — a registry alone + * imposes no constraints (unbound scopes), matching the parsers. + */ + protected isSchemaAware(options: ParseQueryOptions | ParseParameterOptions) : boolean { + return typeof options.schema !== 'undefined' || + typeof options.strict !== 'undefined'; } + /** + * A visitor reset also happens *before* every encode call — + * a failed (thrown) encode must not leak state into the next one. + */ protected runSerializer(serializer: ISerializer) : T { const output = serializer.serialize(); serializer.reset(); diff --git a/packages/codec-url-simple/src/encoder/serializer/record.ts b/packages/codec-url-simple/src/encoder/serializer/record.ts index 413c93540..080075723 100644 --- a/packages/codec-url-simple/src/encoder/serializer/record.ts +++ b/packages/codec-url-simple/src/encoder/serializer/record.ts @@ -28,6 +28,10 @@ export class RecordSerializer< this.value[key] = value; } + has(key: string) : boolean { + return Object.prototype.hasOwnProperty.call(this.value, key); + } + serialize(): string | null { const keys = Object.keys(this.value); if (keys.length === 0) { diff --git a/packages/codec-url-simple/src/encoder/visitors/filters.ts b/packages/codec-url-simple/src/encoder/visitors/filters.ts index 19a538000..3a0943c1b 100644 --- a/packages/codec-url-simple/src/encoder/visitors/filters.ts +++ b/packages/codec-url-simple/src/encoder/visitors/filters.ts @@ -5,7 +5,11 @@ * view the LICENSE file that was distributed with this source code. */ -import { URLFilterOperator } from '@rapiq/parser-simple'; +import { + URLFilterOperator, + parseFilterWireValue, + serializeFilterValue, +} from '@rapiq/parser-simple'; import type { IFilterVisitor, IFiltersVisitor } from '@rapiq/core'; import { AdapterError, @@ -54,147 +58,143 @@ IFilterVisitor { } visitFilter(expr: Filter): RecordSerializer { - const normalized = this.normalizeValue(expr.value); - - if ( - expr.operator === FilterFieldOperator.NOT_EQUAL || - expr.operator === FilterFieldOperator.NOT_IN - ) { - this.serializer.set( - expr.field, - URLFilterOperator.NEGATION + normalized, - ); - - return this.serializer; - } - - if (expr.operator === FilterFieldOperator.LESS_THAN) { - this.serializer.set( - expr.field, - URLFilterOperator.LESS_THAN + normalized, - ); - - return this.serializer; - } - - if (expr.operator === FilterFieldOperator.LESS_THAN_EQUAL) { - this.serializer.set( - expr.field, - URLFilterOperator.LESS_THAN_EQUAL + normalized, - ); - - return this.serializer; - } - - if (expr.operator === FilterFieldOperator.GREATER_THAN) { - this.serializer.set( - expr.field, - URLFilterOperator.GREATER_THAN + normalized, - ); - - return this.serializer; + // the wire record holds one condition per field — a second + // one would silently overwrite the first (changed semantics). + if (this.serializer.has(expr.field)) { + throw AdapterError.featureUnsupported('filters:field:duplicate'); } - if (expr.operator === FilterFieldOperator.GREATER_THAN_EQUAL) { - this.serializer.set( - expr.field, - URLFilterOperator.GREATER_THAN_EQUAL + normalized, - ); + this.serializer.set(expr.field, this.serializeCondition(expr)); - return this.serializer; - } - - if (expr.operator === FilterFieldOperator.CONTAINS) { - this.serializer.set( - expr.field, - URLFilterOperator.LIKE + normalized + URLFilterOperator.LIKE, - ); - - return this.serializer; - } - - if (expr.operator === FilterFieldOperator.NOT_CONTAINS) { - this.serializer.set( - expr.field, - URLFilterOperator.NEGATION + URLFilterOperator.LIKE + normalized + URLFilterOperator.LIKE, - ); - - return this.serializer; - } - - if (expr.operator === FilterFieldOperator.STARTS_WITH) { - this.serializer.set( - expr.field, - normalized + URLFilterOperator.LIKE, - ); - - return this.serializer; - } - - if (expr.operator === FilterFieldOperator.NOT_STARTS_WITH) { - this.serializer.set( - expr.field, - URLFilterOperator.NEGATION + normalized + URLFilterOperator.LIKE, - ); - - return this.serializer; - } - - if (expr.operator === FilterFieldOperator.ENDS_WITH) { - this.serializer.set( - expr.field, - URLFilterOperator.LIKE + normalized, - ); + return this.serializer; + } - return this.serializer; + protected serializeCondition(expr: Filter) : string { + switch (expr.operator) { + case FilterFieldOperator.EQUAL: + case FilterFieldOperator.IN: { + return this.verifyWireValue(expr, serializeFilterValue(expr.value)); + } + case FilterFieldOperator.NOT_EQUAL: + case FilterFieldOperator.NOT_IN: { + return this.verifyWireValue( + expr, + URLFilterOperator.NEGATION + serializeFilterValue(expr.value), + ); + } + case FilterFieldOperator.LESS_THAN: { + return this.verifyWireValue( + expr, + URLFilterOperator.LESS_THAN + serializeFilterValue(expr.value), + ); + } + case FilterFieldOperator.LESS_THAN_EQUAL: { + return this.verifyWireValue( + expr, + URLFilterOperator.LESS_THAN_EQUAL + serializeFilterValue(expr.value), + ); + } + case FilterFieldOperator.GREATER_THAN: { + return this.verifyWireValue( + expr, + URLFilterOperator.GREATER_THAN + serializeFilterValue(expr.value), + ); + } + case FilterFieldOperator.GREATER_THAN_EQUAL: { + return this.verifyWireValue( + expr, + URLFilterOperator.GREATER_THAN_EQUAL + serializeFilterValue(expr.value), + ); + } + case FilterFieldOperator.CONTAINS: { + return this.verifyWireValue( + expr, + URLFilterOperator.LIKE + this.serializeLikeText(expr) + URLFilterOperator.LIKE, + ); + } + case FilterFieldOperator.NOT_CONTAINS: { + return this.verifyWireValue( + expr, + URLFilterOperator.NEGATION + URLFilterOperator.LIKE + + this.serializeLikeText(expr) + URLFilterOperator.LIKE, + ); + } + case FilterFieldOperator.STARTS_WITH: { + return this.verifyWireValue( + expr, + this.serializeLikeText(expr) + URLFilterOperator.LIKE, + ); + } + case FilterFieldOperator.NOT_STARTS_WITH: { + return this.verifyWireValue( + expr, + URLFilterOperator.NEGATION + this.serializeLikeText(expr) + URLFilterOperator.LIKE, + ); + } + case FilterFieldOperator.ENDS_WITH: { + return this.verifyWireValue( + expr, + URLFilterOperator.LIKE + this.serializeLikeText(expr), + ); + } + case FilterFieldOperator.NOT_ENDS_WITH: { + return this.verifyWireValue( + expr, + URLFilterOperator.NEGATION + URLFilterOperator.LIKE + this.serializeLikeText(expr), + ); + } + default: { + // REGEX, MOD, EXISTS, ELEM_MATCH, ... have no simple-dialect + // wire syntax and would decode as plain equality. + throw AdapterError.operatorUnsupported(expr.operator); + } } + } - if (expr.operator === FilterFieldOperator.NOT_ENDS_WITH) { - this.serializer.set( - expr.field, - URLFilterOperator.NEGATION + URLFilterOperator.LIKE + normalized, - ); + /** + * LIKE inner text travels raw (the decoder applies no scalar + * coercion and no comma-splitting to it) — strings pass through + * verbatim, everything else uses the scalar wire form. + */ + protected serializeLikeText(expr: Filter) : string { + if (typeof expr.value === 'string') { + if (expr.value.length === 0) { + // decodes to an empty match text, which the parser drops. + throw AdapterError.featureUnsupported('filters:value:empty'); + } - return this.serializer; + return expr.value; } - this.serializer.set(expr.field, normalized); - - return this.serializer; + return serializeFilterValue(expr.value); } - protected normalizeValue(input: unknown) : string { - if (typeof input === 'string') { - return input; - } - - if ( - typeof input === 'undefined' || - input === 'null' || - input === null - ) { - return 'null'; - } + /** + * Subset law, enforced pointwise: the wire token must decode back + * to the operator it was serialized from (values may still change + * scalar type — that normalization is part of the wire contract). + * The simple dialect has no escaping, so e.g. an EQUAL on the + * string 'foo~' would silently decode as STARTS_WITH 'foo'. + */ + protected verifyWireValue(expr: Filter, wire: string) : string { + const reparsed = parseFilterWireValue(wire); - if (typeof input === 'number') { - return `${input}`; - } + let matches = reparsed.operator === expr.operator; - if (typeof input === 'boolean') { - return input ? 'true' : 'false'; + if (!matches && expr.operator === FilterFieldOperator.IN) { + // scalar lists decode as EQUAL and are lifted to IN by the + // parser when the value is (or splits into) an array. + matches = reparsed.operator === FilterFieldOperator.EQUAL; } - if (input instanceof RegExp) { - return input.source; + if (!matches && expr.operator === FilterFieldOperator.NOT_IN) { + matches = reparsed.operator === FilterFieldOperator.NOT_EQUAL; } - if (Array.isArray(input)) { - return input - .map((el) => this.normalizeValue(el)) - .filter(Boolean) - .join(','); + if (!matches) { + throw AdapterError.featureUnsupported(`filters:value:${expr.field}`); } - throw new Error('Value can not be normalized'); + return wire; } } diff --git a/packages/codec-url-simple/src/encoder/visitors/module.ts b/packages/codec-url-simple/src/encoder/visitors/module.ts index 14f11075b..4be443a17 100644 --- a/packages/codec-url-simple/src/encoder/visitors/module.ts +++ b/packages/codec-url-simple/src/encoder/visitors/module.ts @@ -35,6 +35,14 @@ import { PaginationVisitor } from './pagination'; import { RelationsVisitor } from './relations'; import { SortsVisitor } from './sort'; +export type QueryParameterMask = { + fields?: boolean, + filters?: boolean, + pagination?: boolean, + relations?: boolean, + sorts?: boolean, +}; + export class QueryVisitor implements IQueryVisitor, IFieldsVisitor, IFieldVisitor, @@ -69,12 +77,35 @@ export class QueryVisitor implements IQueryVisitor, this.sort = new SortsVisitor(serializer.sort); } - visitQuery(expr: IQuery): QuerySerializer { - expr.fields.accept(this.fields); - expr.filters.accept(this.filters); - expr.pagination.accept(this.pagination); - expr.relations.accept(this.relations); - expr.sorts.accept(this.sort); + reset() : void { + this.serializer.reset(); + } + + /** + * The optional mask limits which parameters are emitted — + * the schema-aware encode pass uses it to avoid materializing + * schema defaults for parameters absent from the input query. + */ + visitQuery(expr: IQuery, parameters?: QueryParameterMask): QuerySerializer { + if (!parameters || parameters.fields) { + expr.fields.accept(this.fields); + } + + if (!parameters || parameters.filters) { + expr.filters.accept(this.filters); + } + + if (!parameters || parameters.pagination) { + expr.pagination.accept(this.pagination); + } + + if (!parameters || parameters.relations) { + expr.relations.accept(this.relations); + } + + if (!parameters || parameters.sorts) { + expr.sorts.accept(this.sort); + } return this.serializer; } diff --git a/packages/codec-url-simple/test/data/schema.ts b/packages/codec-url-simple/test/data/schema.ts new file mode 100644 index 000000000..5eb333a36 --- /dev/null +++ b/packages/codec-url-simple/test/data/schema.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { SchemaRegistry, defineSchema } from '@rapiq/core'; +import type { Item, Realm, User } from './type'; + +export const userSchema = defineSchema({ + name: 'user', + fields: { allowed: ['id', 'name', 'email', 'age'] }, + filters: { allowed: ['id', 'name', 'email'] }, + relations: { + allowed: ['realm', 'items'], + mapping: { abc: 'items' }, + }, + sort: { allowed: ['id', 'name', 'email'] }, + schemaMapping: { items: 'item' }, +}); + +export const itemSchema = defineSchema({ + name: 'item', + fields: { allowed: ['id'] }, + filters: { allowed: ['id', 'name'] }, + relations: { allowed: ['user', 'realm'] }, + sort: { allowed: ['id'] }, +}); + +export const realmSchema = defineSchema({ + name: 'realm', + fields: { allowed: ['id', 'name', 'description'] }, + filters: { allowed: ['id', 'name'] }, + sort: { allowed: ['id', 'name'] }, +}); + +export const registry = new SchemaRegistry(); +registry.add(userSchema); +registry.add(itemSchema); +registry.add(realmSchema); diff --git a/packages/codec-url-simple/test/data/type.ts b/packages/codec-url-simple/test/data/type.ts new file mode 100644 index 000000000..212c9e34c --- /dev/null +++ b/packages/codec-url-simple/test/data/type.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export type Realm = { + id: number, + name: string, + description: string, +}; + +export type Item = { + id: string, + name: string, + realm: Realm, + user: User +}; + +export type User = { + id: string, + name: string, + email: string, + age: number, + realm: Realm, + items: Item[] +}; diff --git a/packages/codec-url-simple/test/unit/encoder-schema.spec.ts b/packages/codec-url-simple/test/unit/encoder-schema.spec.ts new file mode 100644 index 000000000..76b697653 --- /dev/null +++ b/packages/codec-url-simple/test/unit/encoder-schema.spec.ts @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + ParseError, + defineQuery, + defineSchema, + eq, +} from '@rapiq/core'; +import { URLEncoder } from '../../src'; +import { registry } from '../data/schema'; +import type { User } from '../data/type'; + +/** + * Schema-aware encoding (plan 007 decision 3): the emitted output is + * validated exactly the way the server-side decoder would treat it — + * drop by default, schema-level throwOnFailure opts into throwing. + */ +describe('encoder (schema-aware)', () => { + let encoder : URLEncoder; + + beforeAll(() => { + encoder = new URLEncoder(registry); + }); + + it('should encode without a schema pass by default', () => { + const query = defineQuery({ + fields: ['id', 'secret'], + filters: { secret: 'x' }, + }); + + expect(decodeURIComponent(encoder.encode(query)!)).toEqual( + 'fields=id,secret&filter[secret]=x', + ); + }); + + it('should drop disallowed keys across parameters', () => { + const query = defineQuery({ + fields: ['id', 'secret'], + filters: { name: 'John', secret: 'x' }, + relations: ['realm', 'passwords'], + sort: ['-id', 'secret'], + }); + + const encoded = encoder.encode(query, { schema: 'user' }); + + // included relations materialize their field groups, exactly as + // the server-side decode of this input would resolve them. + expect(decodeURIComponent(encoded!)).toEqual( + 'fields[__DEFAULT__]=id&fields[realm]=id,name,description&filter[name]=John&include=realm&sort=-id', + ); + }); + + it('should apply schema mappings to emitted keys', () => { + const query = defineQuery({ relations: ['abc'] }); + + const encoded = encoder.encode(query, { schema: 'user' }); + + expect(decodeURIComponent(encoded!)).toEqual('include=items'); + }); + + it('should resolve relation paths through the registry', () => { + const query = defineQuery({ + filters: eq('items.name', 'a'), + relations: ['items'], + }); + + const encoded = encoder.encode(query, { schema: 'user' }); + + expect(decodeURIComponent(encoded!)).toEqual( + 'filter[items.name]=a&include=items', + ); + }); + + it('should clamp pagination to the schema maxLimit', () => { + const schema = defineSchema({ pagination: { maxLimit: 50 } }); + + const query = defineQuery({ pagination: { limit: 500, offset: 10 } }); + + const encoded = encoder.encode(query, { schema }); + + expect(decodeURIComponent(encoded!)).toEqual( + 'page[limit]=50&page[offset]=10', + ); + }); + + it('should not materialize defaults for parameters absent from the input', () => { + const schema = defineSchema({ sort: { allowed: ['id', 'name'], default: { name: 'DESC' } } }); + + const query = defineQuery({ filters: { name: 'John' } }); + + const encoded = encoder.encode(query, { schema }); + + // the server applies its sort default on decode anyway — + // validation must not fatten the wire with it. + expect(decodeURIComponent(encoded!)).toEqual('filter[name]=John'); + }); + + it('should throw when the schema opts into throwOnFailure', () => { + const schema = defineSchema({ + filters: { allowed: ['id', 'name'] }, + throwOnFailure: true, + }); + + const query = defineQuery({ filters: { secret: 'x' } }); + + expect(() => encoder.encode(query, { schema })).toThrowError(ParseError); + }); + + it('should reject keys without an allow-list under strict mode', () => { + const schema = defineSchema({ filters: { allowed: ['id', 'name'] } }); + + const query = defineQuery({ + filters: { name: 'John' }, + sort: '-id', + }); + + const encoded = encoder.encode(query, { schema, strict: true }); + + expect(decodeURIComponent(encoded!)).toEqual('filter[name]=John'); + }); + + it('should validate a single parameter through the same pass', () => { + const query = defineQuery({ filters: { name: 'John', secret: 'x' } }); + + const encoded = encoder.encodeFilters(query.filters, { schema: 'user' }); + + expect(decodeURIComponent(encoded!)).toEqual('filter[name]=John'); + }); +}); diff --git a/packages/codec-url-simple/test/unit/roundtrip.spec.ts b/packages/codec-url-simple/test/unit/roundtrip.spec.ts new file mode 100644 index 000000000..4b98f5dbf --- /dev/null +++ b/packages/codec-url-simple/test/unit/roundtrip.spec.ts @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + AdapterError, + ErrorCode, + Field, + Fields, + FilterCompoundOperator, + Filters, + Pagination, + Relation, + Relations, + Sort, + SortDirection, + Sorts, + and, + contains, + defineQuery, + elemMatch, + endsWith, + eq, + exists, + gt, + gte, + inArray, + lt, + lte, + mod, + ne, + nin, + notContains, + notEndsWith, + notStartsWith, + or, + regex, + startsWith, +} from '@rapiq/core'; +import type { IFilter, IQuery } from '@rapiq/core'; +import { URLDecoder, URLEncoder } from '../../src'; + +/** + * The codec contract (plan 007): decode(encode(q)) ≍ q for every query + * within the simple dialect's expressible subset — modulo scalar type + * normalization (the wire is untyped: '5' → 5, 'true' → true) — and a + * loud, typed failure for every query outside it. + */ +describe('round-trip', () => { + const encoder = new URLEncoder(); + const decoder = new URLDecoder(); + + const roundTrip = (query: IQuery) : IQuery => { + const encoded = encoder.encode(query); + expect(encoded).toBeTypeOf('string'); + + const decoded = decoder.decode(encoded!); + expect(decoded).toBeDefined(); + + return decoded!; + }; + + const roundTripFilter = (filter: IFilter) : unknown => roundTrip( + defineQuery({ filters: filter }), + ).filters; + + describe('filters (operator matrix)', () => { + it.each([ + ['eq string', eq('name', 'John')], + ['eq number', eq('age', 18)], + ['eq zero', eq('age', 0)], + ['eq negative number', eq('age', -1)], + ['eq true', eq('flag', true)], + ['eq false', eq('flag', false)], + ['eq null', eq('email', null)], + ['eq date-like string', eq('created_at', '2026-01-01')], + ['ne string', ne('name', 'John')], + ['ne null', ne('email', null)], + ['lt', lt('age', 65)], + ['lte', lte('age', 65)], + ['gt', gt('age', 18)], + ['gte', gte('age', 18)], + ['gte negative', gte('age', -10)], + ['in numbers', inArray('id', [1, 2, 3])], + ['in with null element', inArray('realm_id', ['master', null])], + ['in with boolean elements', inArray('flag', [true, false])], + ['nin', nin('id', [1, 2])], + ['nin with null element', nin('realm_id', ['master', null])], + ['startsWith', startsWith('name', 'Jo')], + ['notStartsWith', notStartsWith('name', 'Jo')], + ['endsWith', endsWith('name', 'hn')], + ['notEndsWith', notEndsWith('name', 'hn')], + ['contains', contains('name', 'oh')], + ['notContains', notContains('name', 'oh')], + ['contains with interior tilde', contains('name', 'a~b')], + ['startsWith numeric-looking text', startsWith('code', '5')], + ])('should round-trip %s', (_, filter) => { + expect(roundTripFilter(filter)).toEqual( + new Filters(FilterCompoundOperator.AND, [filter]), + ); + }); + + it.each([ + [ + 'eq numeric string normalizes to number', + eq('code', '5'), + eq('code', 5), + ], + [ + 'eq boolean-like string normalizes to boolean', + eq('flag', 'true'), + eq('flag', true), + ], + [ + 'eq null-like string normalizes to null', + eq('email', 'null'), + eq('email', null), + ], + [ + 'singleton in normalizes to eq', + inArray('id', [1]), + eq('id', 1), + ], + [ + 'like match text stringifies non-string values', + startsWith('code', 5), + startsWith('code', '5'), + ], + ])('should round-trip %s', (_, filter, expected) => { + expect(roundTripFilter(filter)).toEqual( + new Filters(FilterCompoundOperator.AND, [expected]), + ); + }); + }); + + describe('filters (outside the dialect subset)', () => { + const expectTypedFailure = (filter: Parameters[0], code: string) => { + const query = defineQuery({ filters: filter }); + + try { + encoder.encode(query); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AdapterError); + expect((e as AdapterError).code).toBe(code); + } + }; + + it.each([ + ['regex', regex('name', /^Jo/)], + ['mod', mod('age', [2, 0])], + ['exists', exists('email', true)], + ['elemMatch', elemMatch('items', eq('name', 'a'))], + ])('should throw for the %s operator (no wire syntax)', (_, filter) => { + expectTypedFailure(filter, ErrorCode.OPERATOR_UNSUPPORTED); + }); + + it.each([ + ['or compound', or(gte('age', 18), eq('email', null))], + ['nested compound', and(eq('name', 'John'), or(gte('age', 18), eq('email', null)))], + ['two conditions on the same field', and(gte('age', 18), lt('age', 65))], + ['eq comma string (would decode as in)', eq('name', 'a,b')], + ['in element with comma', inArray('name', ['a,b', 'c'])], + ['contains comma match text (comma-split precedes marker parsing)', contains('name', 'a,b')], + ['eq empty string (would be dropped)', eq('name', '')], + ['empty in list (would be dropped)', inArray('id', [])], + ['contains empty match text (would be dropped)', contains('name', '')], + ['eq trailing tilde (would decode as startsWith)', eq('name', 'foo~')], + ['eq leading negation (would decode as ne)', eq('name', '!x')], + ['eq leading comparison marker (would decode as lt)', eq('name', '<5')], + ['startsWith leading tilde (would decode as contains)', startsWith('name', '~x')], + ['endsWith trailing tilde (would decode as contains)', endsWith('name', 'x~')], + ['startsWith leading negation (would decode negated)', startsWith('name', '!x')], + ])('should throw for %s', (_, filter) => { + expectTypedFailure(filter, ErrorCode.FEATURE_UNSUPPORTED); + }); + }); + + describe('full query', () => { + it('should round-trip every parameter', () => { + const query = defineQuery({ + fields: ['id', 'name'], + filters: and(eq('name', 'John'), gte('age', 18)), + pagination: { limit: 20, offset: 10 }, + relations: ['realm', 'items.realm'], + sort: ['-id', 'name'], + }); + + const decoded = roundTrip(query); + + expect(decoded.fields).toEqual(new Fields([ + new Field('id'), + new Field('name'), + ])); + expect(decoded.filters).toEqual(new Filters(FilterCompoundOperator.AND, [ + eq('name', 'John'), + gte('age', 18), + ])); + expect(decoded.pagination).toEqual(new Pagination(20, 10)); + // nested include paths imply their parents — the decoder + // materializes them (normalization, not lossiness). + expect(decoded.relations).toEqual(new Relations([ + new Relation('items'), + new Relation('realm'), + new Relation('items.realm'), + ])); + expect(decoded.sorts).toEqual(new Sorts([ + new Sort('id', SortDirection.DESC), + new Sort('name', SortDirection.ASC), + ])); + }); + + it('should be idempotent after one round-trip', () => { + const query = defineQuery({ + fields: ['id', 'name'], + filters: { code: '5', flag: 'true' }, + sort: '-id', + }); + + const once = roundTrip(query); + const twice = roundTrip(once); + + expect(encoder.encode(twice)).toEqual(encoder.encode(once)); + expect(twice.filters).toEqual(once.filters); + expect(twice.fields).toEqual(once.fields); + expect(twice.sorts).toEqual(once.sorts); + }); + }); +}); diff --git a/packages/codec-url/package.json b/packages/codec-url/package.json new file mode 100644 index 000000000..8bca6269c --- /dev/null +++ b/packages/codec-url/package.json @@ -0,0 +1,66 @@ +{ + "name": "@rapiq/codec-url", + "version": "1.0.0", + "description": "A package containing a registry dispatching between url codec dialects via an in-band codec parameter.", + "type": "module", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist/" + ], + "dependencies": { + "qs": "^6.15.3" + }, + "devDependencies": { + "@rapiq/codec-url-expression": "^1.0.0", + "@rapiq/codec-url-simple": "^1.0.0", + "@rapiq/core": "^1.0.0", + "@types/qs": "^6.14.0" + }, + "peerDependencies": { + "@rapiq/codec-url-expression": "^1.0.0", + "@rapiq/codec-url-simple": "^1.0.0", + "@rapiq/core": "^1.0.0" + }, + "scripts": { + "build:types": "tsc --noEmit -p tsconfig.build.json", + "build:js": "tsdown", + "build": "npm run build:types && npm run build:js", + "test": "vitest --config test/vitest.config.ts --run", + "test:coverage": "vitest --config test/vitest.config.ts --run --coverage", + "prepublishOnly": "npm run build" + }, + "author": { + "name": "Peter Placzek", + "email": "contact@tada5hi.net", + "url": "https://github.com/tada5hi" + }, + "license": "MIT", + "keywords": [ + "query", + "json", + "json-api", + "api", + "rest", + "api-utils", + "codec", + "registry", + "typescript" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Tada5hi/rapiq.git", + "directory": "packages/codec-url" + }, + "bugs": { + "url": "https://github.com/Tada5hi/rapiq/issues" + }, + "homepage": "https://github.com/Tada5hi/rapiq#readme" +} diff --git a/packages/codec-url/src/constants.ts b/packages/codec-url/src/constants.ts new file mode 100644 index 000000000..ef8bc5910 --- /dev/null +++ b/packages/codec-url/src/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +/** + * Reserved wire parameter carrying the codec identity of a payload. + * Encoding through the registry stamps it; decoding dispatches on it + * and falls back to the registry default when it is absent (plain + * clients keep working without stamping anything). + */ +export const CODEC_PARAMETER = 'codec'; diff --git a/packages/codec-url/src/factory.ts b/packages/codec-url/src/factory.ts new file mode 100644 index 000000000..97bfc766a --- /dev/null +++ b/packages/codec-url/src/factory.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import type { SchemaRegistry } from '@rapiq/core'; +import { + URLDecoder as ExpressionURLDecoder, + URLEncoder as ExpressionURLEncoder, + URL_EXPRESSION_CODEC, +} from '@rapiq/codec-url-expression'; +import { + URLDecoder as SimpleURLDecoder, + URLEncoder as SimpleURLEncoder, + URL_SIMPLE_CODEC, +} from '@rapiq/codec-url-simple'; +import { URLCodecRegistry } from './module'; + +/** + * Create a registry with the bundled dialects registered: + * simple (the default for unstamped payloads) and expression. + * + * @param input + */ +export function createURLCodecRegistry(input?: SchemaRegistry) : URLCodecRegistry { + const registry = new URLCodecRegistry(); + + registry.register({ + name: URL_SIMPLE_CODEC, + encoder: new SimpleURLEncoder(input), + decoder: new SimpleURLDecoder(input), + }); + + registry.register({ + name: URL_EXPRESSION_CODEC, + encoder: new ExpressionURLEncoder(input), + decoder: new ExpressionURLDecoder(input), + }); + + return registry; +} diff --git a/packages/codec-url/src/index.ts b/packages/codec-url/src/index.ts new file mode 100644 index 000000000..76e7e924a --- /dev/null +++ b/packages/codec-url/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export * from './constants'; +export * from './factory'; +export * from './module'; +export * from './types'; diff --git a/packages/codec-url/src/module.ts b/packages/codec-url/src/module.ts new file mode 100644 index 000000000..b5ed00d96 --- /dev/null +++ b/packages/codec-url/src/module.ts @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { CodecError, isObject } from '@rapiq/core'; +import type { + IQuery, + ObjectLiteral, + ParseQueryOptions, +} from '@rapiq/core'; +import { parse } from 'qs'; +import { CODEC_PARAMETER } from './constants'; +import type { + URLCodec, + URLCodecRegistryEncodeOptions, +} from './types'; + +/** + * Dispatches between URL codec dialects via the in-band + * {@link CODEC_PARAMETER}: encoding stamps the codec identity onto + * the wire, decoding reads it and delegates to the matching codec. + * A payload without the parameter falls back to the registry + * default; a payload naming an unregistered codec fails loudly — + * it must never silently mis-decode under another dialect. + */ +export class URLCodecRegistry { + protected items : Map; + + protected defaultName : string | undefined; + + constructor() { + this.items = new Map(); + } + + /** + * Register a codec. The first registered codec becomes the + * default (used for unstamped payloads and stampless encodes) + * unless a later one is registered with `asDefault`. + * + * @param codec + * @param asDefault + */ + register(codec: URLCodec, asDefault?: boolean) : void { + this.items.set(codec.name, codec); + + if (asDefault || typeof this.defaultName === 'undefined') { + this.defaultName = codec.name; + } + } + + has(name: string) : boolean { + return this.items.has(name); + } + + /** + * Encode a query with the named codec (or the default) and stamp + * the codec identity onto the wire. + * + * @param input + * @param options + */ + encode(input: IQuery, options: URLCodecRegistryEncodeOptions = {}) : string | null { + const { codec: name, ...parseOptions } = options; + + const codec = this.resolve(name); + const encoded = codec.encoder.encode(input, parseOptions); + if (encoded === null) { + return null; + } + + return `${CODEC_PARAMETER}=${codec.name}&${encoded}`; + } + + /** + * Decode a query string or a pre-parsed query object (e.g. an + * express req.query) with the codec its payload names — or the + * default codec when the identity parameter is absent. + * + * @param input + * @param options + */ + decode( + input: string | ObjectLiteral, + options: ParseQueryOptions = {}, + ) : IQuery | null { + const parsed = typeof input === 'string' ? parse(input) : input; + if (!isObject(parsed)) { + return null; + } + + let name : string | undefined; + + const value = parsed[CODEC_PARAMETER]; + if (typeof value !== 'undefined') { + if (typeof value !== 'string') { + throw CodecError.notResolvable(); + } + + name = value; + } + + const codec = this.resolve(name); + + // the registry owns the reserved parameter — delegated + // decoders (especially external ones) must not see it. + const { [CODEC_PARAMETER]: _, ...payload } = parsed; + + return codec.decoder.decode(payload, options); + } + + protected resolve(name?: string) : URLCodec { + const key = name ?? this.defaultName; + if (typeof key === 'undefined') { + throw CodecError.notResolvable(); + } + + const codec = this.items.get(key); + if (typeof codec === 'undefined') { + throw CodecError.notResolvable(key); + } + + return codec; + } +} diff --git a/packages/codec-url/src/types.ts b/packages/codec-url/src/types.ts new file mode 100644 index 000000000..a86920dba --- /dev/null +++ b/packages/codec-url/src/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import type { + IQuery, + ObjectLiteral, + ParseQueryOptions, +} from '@rapiq/core'; + +export interface IURLCodecEncoder { + encode(input: IQuery, options?: ParseQueryOptions): string | null; +} + +export interface IURLCodecDecoder { + decode(input: string | ObjectLiteral, options?: ParseQueryOptions): IQuery | null; +} + +/** + * A registrable URL codec: a stable identifier plus the two wire + * directions. The bundled dialects (@rapiq/codec-url-simple, + * @rapiq/codec-url-expression) satisfy the encoder/decoder contracts + * structurally; external codecs implement the same shape. + */ +export type URLCodec = { + name: string, + encoder: IURLCodecEncoder, + decoder: IURLCodecDecoder, +}; + +export type URLCodecRegistryEncodeOptions = ParseQueryOptions & { + /** + * Name of the codec to encode with; the registry + * default is used when omitted. + */ + codec?: string, +}; diff --git a/packages/codec-url/test/unit/registry.spec.ts b/packages/codec-url/test/unit/registry.spec.ts new file mode 100644 index 000000000..5fd55f814 --- /dev/null +++ b/packages/codec-url/test/unit/registry.spec.ts @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + CodecError, + ErrorCode, + Filters, + defineQuery, + eq, + gte, + or, +} from '@rapiq/core'; +import type { IQuery } from '@rapiq/core'; +import { URLCodecRegistry, createURLCodecRegistry } from '../../src'; + +describe('URLCodecRegistry', () => { + const registry = createURLCodecRegistry(); + + it('should stamp the codec identity when encoding', () => { + const query = defineQuery({ filters: { name: 'John' } }); + + const encoded = registry.encode(query); + + expect(decodeURIComponent(encoded!)).toEqual( + 'codec=url-simple&filter[name]=John', + ); + }); + + it('should encode with an explicitly named codec', () => { + const query = defineQuery({ filters: or(eq('name', 'John'), gte('age', 18)) }); + + const encoded = registry.encode(query, { codec: 'url-expression' }); + + expect(decodeURIComponent(encoded!)).toEqual( + 'codec=url-expression&filter=or(eq(name,\'John\'),gte(age,\'18\'))', + ); + }); + + it('should dispatch decoding on the stamped identity', () => { + const query = defineQuery({ filters: or(eq('name', 'John'), gte('age', 18)) }); + + const decoded = registry.decode(registry.encode(query, { codec: 'url-expression' })!); + + expect(decoded!.filters).toEqual(or(eq('name', 'John'), gte('age', 18))); + }); + + it('should fall back to the default codec for unstamped payloads', () => { + const decoded = registry.decode('filter[name]=John'); + + expect(decoded!.filters).toEqual(new Filters('and', [eq('name', 'John')])); + }); + + it('should decode a pre-parsed query object (req.query)', () => { + const decoded = registry.decode({ + codec: 'url-expression', + filter: 'or(eq(name,\'John\'),gte(age,\'18\'))', + }); + + expect(decoded!.filters).toEqual(or(eq('name', 'John'), gte('age', 18))); + }); + + it('should fail loudly for an unregistered stamped codec', () => { + try { + registry.decode('codec=url-mongo&filter[name]=John'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(CodecError); + expect((e as CodecError).code).toBe(ErrorCode.CODEC_UNRESOLVABLE); + } + }); + + it('should throw when no codec is registered', () => { + const empty = new URLCodecRegistry(); + + expect(() => empty.encode(defineQuery({ filters: { a: 1 } }))).toThrowError(CodecError); + }); + + it('should dispatch to an externally registered codec', () => { + const external = new URLCodecRegistry(); + external.register({ + name: 'noop', + encoder: { encode: () => 'x=1' }, + decoder: { decode: () => null }, + }); + + expect(external.encode(defineQuery({ filters: { a: 1 } }))).toEqual('codec=noop&x=1'); + expect(external.decode('codec=noop')).toBeNull(); + }); + + it('should strip the reserved parameter before delegating', () => { + const seen : unknown[] = []; + + const external = new URLCodecRegistry(); + external.register({ + name: 'noop', + encoder: { encode: () => null }, + decoder: { + decode: (input) => { + seen.push(input); + return null; + }, + }, + }); + + external.decode('codec=noop&filter[name]=John'); + external.decode({ codec: 'noop', filter: { name: 'John' } }); + + expect(seen).toEqual([ + { filter: { name: 'John' } }, + { filter: { name: 'John' } }, + ]); + }); + + it('should return null for an empty query', () => { + expect(registry.encode(defineQuery({}) as IQuery)).toBeNull(); + }); +}); diff --git a/packages/codec-url/test/vitest.config.ts b/packages/codec-url/test/vitest.config.ts new file mode 100644 index 000000000..f4afaedb4 --- /dev/null +++ b/packages/codec-url/test/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/unit/**/*.{spec,test}.{ts,js}'], + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx,js,jsx}'], + exclude: ['src/**/*.d.ts'], + }, + }, +}); diff --git a/packages/codec-url/tsconfig.build.json b/packages/codec-url/tsconfig.build.json new file mode 100644 index 000000000..66bb87a91 --- /dev/null +++ b/packages/codec-url/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"] +} diff --git a/packages/codec-url/tsconfig.json b/packages/codec-url/tsconfig.json new file mode 100644 index 000000000..b007e435c --- /dev/null +++ b/packages/codec-url/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/codec-url/tsdown.config.ts b/packages/codec-url/tsdown.config.ts new file mode 100644 index 000000000..c41c74fca --- /dev/null +++ b/packages/codec-url/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: 'src/index.ts', + format: 'esm', + dts: true, + sourcemap: true, + tsconfig: 'tsconfig.build.json', +}); diff --git a/packages/core/src/errors/code.ts b/packages/core/src/errors/code.ts index aeb673e74..1c365b804 100644 --- a/packages/core/src/errors/code.ts +++ b/packages/core/src/errors/code.ts @@ -27,4 +27,6 @@ export enum ErrorCode { FEATURE_UNSUPPORTED = 'featureUnsupported', FILTERS_NOT_FLAT = 'filtersNotFlat', + + CODEC_UNRESOLVABLE = 'codecUnresolvable', } diff --git a/packages/core/src/errors/codec.ts b/packages/core/src/errors/codec.ts new file mode 100644 index 000000000..8d03fd874 --- /dev/null +++ b/packages/core/src/errors/codec.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { isObject } from '../utils'; +import { BaseError } from './base'; +import { ErrorCode } from './code'; +import type { BaseErrorOptions } from './types'; + +export class CodecError extends BaseError { + constructor(message?: string | BaseErrorOptions) { + if (isObject(message)) { + message.message = message.message || 'A codec error has occurred.'; + } + + super(message || 'A codec error has occurred.'); + } + + static notResolvable(name?: string) { + return new this({ + message: name ? + `The codec ${name} could not be resolved.` : + 'No codec could be resolved.', + code: ErrorCode.CODEC_UNRESOLVABLE, + }); + } +} diff --git a/packages/core/src/errors/index.ts b/packages/core/src/errors/index.ts index d3ed6ead7..01eca56fd 100644 --- a/packages/core/src/errors/index.ts +++ b/packages/core/src/errors/index.ts @@ -9,5 +9,6 @@ export * from './adapter'; export * from './base'; export * from './build'; export * from './code'; +export * from './codec'; export * from './merge'; export * from './parse'; diff --git a/packages/docs/.vitepress/config.mjs b/packages/docs/.vitepress/config.mjs index 207d76752..f7afcdef6 100644 --- a/packages/docs/.vitepress/config.mjs +++ b/packages/docs/.vitepress/config.mjs @@ -78,6 +78,12 @@ export default defineConfig({ { text: 'Sort', link: '/guide/sort' }, ], }, + { + text: 'Migration', + items: [ + { text: 'Migration from v1', link: '/guide/migration' }, + ], + }, ], '/integrations/': [ { diff --git a/packages/docs/guide/migration.md b/packages/docs/guide/migration.md new file mode 100644 index 000000000..aff21e8f3 --- /dev/null +++ b/packages/docs/guide/migration.md @@ -0,0 +1,55 @@ +# Migration from v1 + +Running log of intentional behavior changes vs rapiq v1 (and +typeorm-extension), recorded as they are introduced. The full +migration guide is assembled from these entries once v2 stabilizes. + +## Filters + +### `~` prefix position (breaking) + +In v1, `~text` meant *starts with* (`text%`) — the only LIKE form the +dialect had. v2 keeps its richer, position-based mapping instead: + +| Wire value | v1 | v2 | +|---|---|---| +| `text~` | — | starts with (`text%`) | +| `~text` | starts with (`text%`) | ends with (`%text`) | +| `~text~` | — | contains (`%text%`) | + +**v1 clients sending `~text` change meaning from starts-with to +ends-with.** Rewrite them to `text~` (or move to typed +[condition helpers](/guide/build#condition-helpers) / the +[expression dialect](/integrations/url#expression-dialect), which have +no positional magic). + +### Expression dialect: quoted values are never comma-split + +`eq(name, 'a,b')` now parses to the plain string `'a,b'`. Lists are +expressed as separate arguments (`in(status, 'a', 'b')`), not comma +strings. + +## Codecs + +### Loud failures instead of silent lossiness (breaking) + +v1's URL build silently emitted whatever it was given. v2's `encode` +throws typed errors (`FEATURE_UNSUPPORTED`, `OPERATOR_UNSUPPORTED`) +for queries the wire dialect cannot represent — nested compounds, +`or(...)`, same-field conditions, regex/mod/exists/elemMatch, values +that would re-parse as a different condition. See the +[round-trip guarantee](/integrations/url#the-round-trip-guarantee). + +## Server pipeline + +### Strict mode is opt-in + +typeorm-extension rejected parameters without an allow-list; v2 +permits them unless `strict` is enabled (schema- or parse-level). +Security-sensitive consumers should enable it when migrating. + +### TypeORM joins default to LEFT + +typeorm-extension used inner joins for relations; `@rapiq/typeorm` +defaults to left joins (override per relation via the adapter's +`onJoin` hook). diff --git a/packages/docs/integrations/index.md b/packages/docs/integrations/index.md index eba90ef67..519be6bd6 100644 --- a/packages/docs/integrations/index.md +++ b/packages/docs/integrations/index.md @@ -9,12 +9,15 @@ Everything outside `@rapiq/core` is an integration: parsers turn input *into* th | `@rapiq/parser-simple` | Plain objects & arrays (URL-query-like) | [Simple Parser](/integrations/simple) | | `@rapiq/parser-expression` | Expression strings (`and(eq(name, 'John'), gte(age, '18'))`) | [Expression Parser](/integrations/expression) | | `@rapiq/codec-url-simple` | Raw URL query strings (decode) | [URL Codec](/integrations/url) | +| `@rapiq/codec-url-expression` | Raw URL query strings, expression filter dialect (decode) | [URL Codec](/integrations/url#expression-dialect) | ## Query → transport | Package | Output | Page | |---|---|---| | `@rapiq/codec-url-simple` | URL query strings (encode) | [URL Codec](/integrations/url) | +| `@rapiq/codec-url-expression` | URL query strings carrying nested filter compounds | [URL Codec](/integrations/url#expression-dialect) | +| `@rapiq/codec-url` | Dialect dispatch via in-band `codec` parameter | [URL Codec](/integrations/url#codec-registry) | ## Query → backend diff --git a/packages/docs/integrations/url.md b/packages/docs/integrations/url.md index 9b4511c1e..baa0f4544 100644 --- a/packages/docs/integrations/url.md +++ b/packages/docs/integrations/url.md @@ -20,6 +20,42 @@ The output uses the JSON:API-style parameter names (`fields`, `filter`, `page`, Per-parameter encoders exist too: `encodeFields`, `encodeFilters`, `encodePagination`, `encodeRelations`, `encodeSort`. +### The round-trip guarantee + +Every dialect expresses only a **subset** of the query AST. Within that subset the codec guarantees `decode(encode(query)) ≍ query` — equal up to *scalar type normalization*: the wire is untyped, so `'5'` comes back as the number `5`, `'true'` as `true`, `'null'` as `null`, and surrounding whitespace is trimmed. + +Outside the subset, `encode` **throws a typed error** instead of silently changing the query's meaning: + +| Outside the simple dialect | Error code | +|---|---| +| `or(...)` compounds, nested compound groups | `FEATURE_UNSUPPORTED` | +| Two conditions on the same field | `FEATURE_UNSUPPORTED` | +| `REGEX`, `MOD`, `EXISTS`, `ELEM_MATCH` operators | `OPERATOR_UNSUPPORTED` | +| Comma-containing strings (would decode as `IN`) | `FEATURE_UNSUPPORTED` | +| Empty strings / empty `IN` lists (would be dropped) | `FEATURE_UNSUPPORTED` | +| Values colliding with operator markers (`'foo~'`, `'!x'`, `'<5'`) | `FEATURE_UNSUPPORTED` | + +The last row is enforced pointwise: the encoder re-parses every wire token it emits and rejects it if the operator would not survive the trip. If your filters need any of these, use the [expression dialect](#expression-dialect). + +### Schema-aware encoding + +Pass a schema to validate the output the same way the server-side decoder would treat it — early feedback on the client, exact parser semantics: + +```typescript +import { SchemaRegistry } from '@rapiq/core'; +import { URLEncoder } from '@rapiq/codec-url-simple'; + +const encoder = new URLEncoder(registry); // SchemaRegistry, like URLDecoder + +encoder.encode(query, { schema: 'user' }); +``` + +- Disallowed fields/filters/relations/sort keys are **dropped** by default; a schema with `throwOnFailure: true` throws instead. +- Schema `mapping` aliases resolve to their canonical names on the wire. +- `pagination.maxLimit` clamps the emitted limit. +- Parameters absent from the input query stay absent — schema defaults are *not* materialized onto the wire (the server applies them on decode anyway). +- `strict` mode is honored, mirroring the parsers. + ## Decoding ```typescript @@ -58,3 +94,85 @@ Per-parameter variants exist as well: `decodeFields`, `decodeFilters`, | pagination | `page` | `page[limit]=25&page[offset]=50` | | relations | `include` | `include=realm,items` | | sort | `sort` | `sort=name,-age` | + +## Expression dialect + +`@rapiq/codec-url-expression` carries the filters parameter as a single +[expression](/integrations/expression) — nested `and`/`or` compounds cross the +URL boundary first-class. The other four parameters share the simple codec's +wire format. + +```sh +npm install @rapiq/core @rapiq/parser-expression @rapiq/codec-url-simple @rapiq/codec-url-expression +``` + +```typescript +import { URLDecoder, URLEncoder } from '@rapiq/codec-url-expression'; +import { and, defineQuery, eq, gte, or } from '@rapiq/core'; + +const encoder = new URLEncoder(); + +const query = defineQuery({ + filters: and(eq('name', 'John'), or(gte('age', 18), eq('email', null))), + pagination: { limit: 20 }, +}); + +encoder.encode(query); +// filter=and(eq(name,'John'),or(gte(age,'18'),eq(email,null)))&page[limit]=20 +``` + +Its expressible subset is wider than the simple dialect's: nested compounds, +several conditions on the same field and comma-containing strings all +round-trip (values are quoted, `''` escapes a quote). Still outside it — +and loudly rejected on encode: + +| Outside the expression dialect | Error code | +|---|---| +| `REGEX`, `MOD`, `EXISTS`, `ELEM_MATCH` operators | `OPERATOR_UNSUPPORTED` | +| Match text that coerces to a non-string (`startsWith(code, '5')`) | `FEATURE_UNSUPPORTED` | +| Field segments colliding with grammar keywords (`null`, `eq`, …) | `FEATURE_UNSUPPORTED` | +| Empty nested compound groups | `FEATURE_UNSUPPORTED` | + +Schema-aware encoding works exactly like the simple codec's, with one dialect +difference: the expression filters parser is *precise* — a schema violation in +filters always throws (`FiltersParseError`), it is never silently dropped. + +## Codec registry + +Which codec produced a payload is API-contract metadata — but a gateway that +forwards queries it did not author cannot always know it out-of-band. +`@rapiq/codec-url` makes payloads self-describing: encoding through the +registry stamps a reserved `codec` parameter, decoding dispatches on it. + +```sh +npm install @rapiq/core @rapiq/codec-url-simple @rapiq/codec-url-expression @rapiq/codec-url +``` + +```typescript +import { createURLCodecRegistry } from '@rapiq/codec-url'; + +const codecs = createURLCodecRegistry(schemaRegistry); + +codecs.encode(query); +// codec=url-simple&filter[name]=John + +codecs.encode(query, { codec: 'url-expression' }); +// codec=url-expression&filter=or(eq(name,'John'),gte(age,'18')) + +codecs.decode('codec=url-expression&filter=or(...)', { schema: 'user' }); +codecs.decode('filter[name]=John'); // no stamp → default codec (simple) +``` + +Dispatch rules: + +- An **unstamped** payload falls back to the registry default — plain clients + and hand-written URLs keep working. +- A payload naming an **unregistered** codec throws a typed `CodecError` + (`ErrorCode.CODEC_UNRESOLVABLE`) — it is never silently mis-decoded under + another dialect. + +`createURLCodecRegistry()` bundles the two built-in dialects with +`url-simple` as the default. Custom codecs implement the `URLCodec` shape +(`{ name, encoder, decoder }`) and register on a plain `URLCodecRegistry`; +each bundled package exports its identifier (`URL_SIMPLE_CODEC`, +`URL_EXPRESSION_CODEC`) for out-of-band negotiation (e.g. headers) as well. diff --git a/packages/parser-expression/src/parameter/filters/module.ts b/packages/parser-expression/src/parameter/filters/module.ts index a0b591c4c..e3241c78c 100644 --- a/packages/parser-expression/src/parameter/filters/module.ts +++ b/packages/parser-expression/src/parameter/filters/module.ts @@ -26,6 +26,7 @@ import { ResolutionScope, isFilters, } from '@rapiq/core'; +import { parseFilterScalar } from '@rapiq/parser-simple'; import { FilterTokenType } from './constants'; import type { FilterToken } from './types'; @@ -490,38 +491,17 @@ export class ExpressionFiltersParser extends BaseParser< return trimmed; } + // quoted content is coerced but never comma-split — + // the expression dialect passes lists as separate args. if ( - input.startsWith('\'') && - input.endsWith('\'') + trimmed.startsWith('\'') && + trimmed.endsWith('\'') && + trimmed.length >= 2 ) { - return this.normalizeValue(trimmed.slice(1, -1).replace(/''/g, '\'')); + return parseFilterScalar(trimmed.slice(1, -1).replace(/''/g, '\'')); } - const lower = trimmed.toLowerCase(); - - if (lower === 'true') { - return true; - } - - if (lower === 'false') { - return false; - } - - if (lower === 'null') { - return null; - } - - const num = Number(trimmed); - if (!Number.isNaN(num)) { - return num; - } - - const parts = trimmed.split(','); - if (parts.length > 1) { - return this.normalizeValue(parts); - } - - return trimmed; + return parseFilterScalar(trimmed); } if (typeof input === 'number') { diff --git a/packages/parser-simple/src/parameter/filters/index.ts b/packages/parser-simple/src/parameter/filters/index.ts index 97c34f6d2..48df7b6f7 100644 --- a/packages/parser-simple/src/parameter/filters/index.ts +++ b/packages/parser-simple/src/parameter/filters/index.ts @@ -8,3 +8,4 @@ export * from './constants'; export * from './module'; export * from './types'; +export * from './value'; diff --git a/packages/parser-simple/src/parameter/filters/module.ts b/packages/parser-simple/src/parameter/filters/module.ts index 159ce20e2..9468bc9c4 100644 --- a/packages/parser-simple/src/parameter/filters/module.ts +++ b/packages/parser-simple/src/parameter/filters/module.ts @@ -10,7 +10,6 @@ import { DEFAULT_ID, Filter, FilterCompoundOperator, - FilterFieldOperator, Filters, FiltersParseError, Parameter, @@ -21,19 +20,19 @@ import { } from '@rapiq/core'; import type { + FilterFieldOperator, FiltersParseOptions, FiltersSchema, ICondition, IFilter, IFilters, ObjectLiteral, - Scalar, TempType, } from '@rapiq/core'; -import { URLFilterOperator } from './constants'; import type { SimpleFiltersParserInput } from './types'; +import { parseFilterWireValue } from './value'; export class SimpleFiltersParser extends BaseParser< FiltersParseOptions, @@ -225,201 +224,10 @@ export class SimpleFiltersParser extends BaseParser< value: unknown, operator: `${FilterFieldOperator}` } | undefined { - let value : Scalar | Scalar[]; - try { - value = this.normalizeValue(input); + return parseFilterWireValue(input); } catch { return undefined; } - - if (typeof value !== 'string' && !Array.isArray(value)) { - return { value, operator: FilterFieldOperator.EQUAL }; - } - - if (Array.isArray(value)) { - const [first, ...rest] = value; - if (typeof first === 'string') { - const parsed = this.parseStringValue(first); - if (parsed.operator === FilterFieldOperator.NOT_EQUAL) { - return { - value: [parsed.value, ...rest], - operator: FilterFieldOperator.NOT_IN, - }; - } - } - - return { value, operator: FilterFieldOperator.IN }; - } - - return this.parseStringValue(value); - } - - protected parseStringValue(value: string) : { - operator: `${FilterFieldOperator}`, - value: unknown - } { - let hasNegation = false; - - if ( - value.substring(0, 1) === URLFilterOperator.NEGATION - ) { - value = value.substring(1); - hasNegation = true; - } - - const hasLikeStart = value.substring(0, URLFilterOperator.LIKE.length) === URLFilterOperator.LIKE; - const hasLikeEnd = value.substring(value.length - URLFilterOperator.LIKE.length) === URLFilterOperator.LIKE; - - if (hasLikeStart && hasLikeEnd) { - if (hasNegation) { - return { - value: value.substring(URLFilterOperator.LIKE.length, value.length - URLFilterOperator.LIKE.length), - operator: FilterFieldOperator.NOT_CONTAINS, - }; - } - - return { - value: value.substring(URLFilterOperator.LIKE.length, value.length - URLFilterOperator.LIKE.length), - operator: FilterFieldOperator.CONTAINS, - }; - } - - if (hasLikeStart) { - if (hasNegation) { - return { - value: value.substring(URLFilterOperator.LIKE.length), - operator: FilterFieldOperator.NOT_ENDS_WITH, - }; - } - - return { - value: value.substring(URLFilterOperator.LIKE.length), - operator: FilterFieldOperator.ENDS_WITH, - }; - } - - if (hasLikeEnd) { - if (hasNegation) { - return { - value: value.substring(0, value.length - URLFilterOperator.LIKE.length), - operator: FilterFieldOperator.NOT_STARTS_WITH, - }; - } - - return { - value: value.substring(0, value.length - URLFilterOperator.LIKE.length), - operator: FilterFieldOperator.STARTS_WITH, - }; - } - - if ( - value.substring(0, URLFilterOperator.LESS_THAN_EQUAL.length) === URLFilterOperator.LESS_THAN_EQUAL - ) { - return { - value: this.normalizeValue(value.substring(URLFilterOperator.LESS_THAN_EQUAL.length)), - operator: FilterFieldOperator.LESS_THAN_EQUAL, - }; - } - - if ( - value.substring(0, URLFilterOperator.LESS_THAN.length) === URLFilterOperator.LESS_THAN - ) { - return { - value: this.normalizeValue(value.substring(URLFilterOperator.LESS_THAN.length)), - operator: FilterFieldOperator.LESS_THAN, - }; - } - - if ( - value.substring(0, URLFilterOperator.GREATER_THAN_EQUAL.length) === URLFilterOperator.GREATER_THAN_EQUAL - ) { - return { - value: this.normalizeValue(value.substring(URLFilterOperator.GREATER_THAN_EQUAL.length)), - operator: FilterFieldOperator.GREATER_THAN_EQUAL, - }; - } - - if ( - value.substring(0, URLFilterOperator.GREATER_THAN.length) === URLFilterOperator.GREATER_THAN - ) { - return { - value: this.normalizeValue(value.substring(URLFilterOperator.GREATER_THAN.length)), - operator: FilterFieldOperator.GREATER_THAN, - }; - } - - if (hasNegation) { - return { - value: this.normalizeValue(value), - operator: FilterFieldOperator.NOT_EQUAL, - }; - } - - return { - value: this.normalizeValue(value), - operator: FilterFieldOperator.EQUAL, - }; - } - - protected normalizeValue(input: unknown) : Scalar | Scalar[] { - if (typeof input === 'string') { - const trimmed = input.trim(); - if (trimmed.length === 0) { - return trimmed; - } - - const lower = trimmed.toLowerCase(); - - if (lower === 'true') { - return true; - } - - if (lower === 'false') { - return false; - } - - if (lower === 'null') { - return null; - } - - const num = Number(trimmed); - if (!Number.isNaN(num)) { - return num; - } - - const parts = trimmed.split(','); - if (parts.length > 1) { - return this.normalizeValue(parts); - } - - return trimmed; - } - - if (typeof input === 'number') { - return input; - } - - if (Array.isArray(input)) { - const output : Scalar[] = []; - - for (const element of input) { - const temp = this.normalizeValue(element); - if (Array.isArray(temp)) { - output.push(...temp); - } else { - output.push(temp); - } - } - - return output - .filter((n) => n === 0 || n === null || !!n); - } - - if (typeof input === 'undefined' || input === null) { - return null; - } - - throw new SyntaxError('Value can not be normalized.'); } } diff --git a/packages/parser-simple/src/parameter/filters/value.ts b/packages/parser-simple/src/parameter/filters/value.ts new file mode 100644 index 000000000..a4049ff56 --- /dev/null +++ b/packages/parser-simple/src/parameter/filters/value.ts @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { AdapterError, FilterFieldOperator } from '@rapiq/core'; +import type { Scalar } from '@rapiq/core'; +import { URLFilterOperator } from './constants'; + +/** + * Coerce a wire string to its typed scalar form: + * 'true'/'false' → boolean, 'null' → null, numeric → number, + * anything else → trimmed string. The comma-split array + * convention is NOT applied here — this is the pure scalar + * normalization shared by the simple & expression dialects. + * + * @param input + */ +export function parseFilterScalar(input: string) : Scalar { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const lower = trimmed.toLowerCase(); + + if (lower === 'true') { + return true; + } + + if (lower === 'false') { + return false; + } + + if (lower === 'null') { + return null; + } + + const num = Number(trimmed); + if (!Number.isNaN(num)) { + return num; + } + + return trimmed; +} + +/** + * Parse a simple-dialect wire value: scalar coercion plus the + * comma-split array convention and array flattening. + * + * @param input + */ +export function parseFilterValue(input: unknown) : Scalar | Scalar[] { + if (typeof input === 'string') { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const parts = trimmed.split(','); + if (parts.length > 1) { + return parseFilterValue(parts); + } + + return parseFilterScalar(trimmed); + } + + if (typeof input === 'number') { + return input; + } + + if (Array.isArray(input)) { + const output : Scalar[] = []; + + for (const element of input) { + const temp = parseFilterValue(element); + if (Array.isArray(temp)) { + output.push(...temp); + } else { + output.push(temp); + } + } + + return output + .filter((n) => n === 0 || n === false || n === null || !!n); + } + + if (typeof input === 'undefined' || input === null) { + return null; + } + + throw new SyntaxError('Value can not be normalized.'); +} + +/** + * Parse a single already-normalized wire token (operator markers + + * value): leading '!' (negation), '~'-style LIKE markers and + * comparison prefixes ('<', '<=', '>', '>='). LIKE inner text stays + * a raw string; all other values pass through {@link parseFilterValue}. + */ +function parseFilterWireToken(input: string) : { + operator: `${FilterFieldOperator}`, + value: unknown +} { + let value = input; + let hasNegation = false; + + if ( + value.substring(0, 1) === URLFilterOperator.NEGATION + ) { + value = value.substring(1); + hasNegation = true; + } + + const hasLikeStart = value.substring(0, URLFilterOperator.LIKE.length) === URLFilterOperator.LIKE; + const hasLikeEnd = value.substring(value.length - URLFilterOperator.LIKE.length) === URLFilterOperator.LIKE; + + if (hasLikeStart && hasLikeEnd) { + return { + value: value.substring(URLFilterOperator.LIKE.length, value.length - URLFilterOperator.LIKE.length), + operator: hasNegation ? + FilterFieldOperator.NOT_CONTAINS : + FilterFieldOperator.CONTAINS, + }; + } + + if (hasLikeStart) { + return { + value: value.substring(URLFilterOperator.LIKE.length), + operator: hasNegation ? + FilterFieldOperator.NOT_ENDS_WITH : + FilterFieldOperator.ENDS_WITH, + }; + } + + if (hasLikeEnd) { + return { + value: value.substring(0, value.length - URLFilterOperator.LIKE.length), + operator: hasNegation ? + FilterFieldOperator.NOT_STARTS_WITH : + FilterFieldOperator.STARTS_WITH, + }; + } + + if ( + value.substring(0, URLFilterOperator.LESS_THAN_EQUAL.length) === URLFilterOperator.LESS_THAN_EQUAL + ) { + return { + value: parseFilterValue(value.substring(URLFilterOperator.LESS_THAN_EQUAL.length)), + operator: FilterFieldOperator.LESS_THAN_EQUAL, + }; + } + + if ( + value.substring(0, URLFilterOperator.LESS_THAN.length) === URLFilterOperator.LESS_THAN + ) { + return { + value: parseFilterValue(value.substring(URLFilterOperator.LESS_THAN.length)), + operator: FilterFieldOperator.LESS_THAN, + }; + } + + if ( + value.substring(0, URLFilterOperator.GREATER_THAN_EQUAL.length) === URLFilterOperator.GREATER_THAN_EQUAL + ) { + return { + value: parseFilterValue(value.substring(URLFilterOperator.GREATER_THAN_EQUAL.length)), + operator: FilterFieldOperator.GREATER_THAN_EQUAL, + }; + } + + if ( + value.substring(0, URLFilterOperator.GREATER_THAN.length) === URLFilterOperator.GREATER_THAN + ) { + return { + value: parseFilterValue(value.substring(URLFilterOperator.GREATER_THAN.length)), + operator: FilterFieldOperator.GREATER_THAN, + }; + } + + return { + value: parseFilterValue(value), + operator: hasNegation ? + FilterFieldOperator.NOT_EQUAL : + FilterFieldOperator.EQUAL, + }; +} + +/** + * Decode a complete simple-dialect wire value to its operator and + * typed value: {@link parseFilterValue} normalization first (scalar + * coercion, comma-split), then a comma-split (or already-array) value + * becomes IN — lifted to NOT_IN when its first element is negated — + * and a remaining string is parsed for operator markers. + * + * Throws a SyntaxError when the value can not be normalized. + * + * @param input + */ +export function parseFilterWireValue(input: unknown) : { + operator: `${FilterFieldOperator}`, + value: unknown +} { + const value = parseFilterValue(input); + + if (Array.isArray(value)) { + const [first, ...rest] = value; + if (typeof first === 'string') { + const parsed = parseFilterWireToken(first); + if (parsed.operator === FilterFieldOperator.NOT_EQUAL) { + return { + value: [parsed.value, ...rest], + operator: FilterFieldOperator.NOT_IN, + }; + } + } + + return { value, operator: FilterFieldOperator.IN }; + } + + if (typeof value !== 'string') { + return { value, operator: FilterFieldOperator.EQUAL }; + } + + return parseFilterWireToken(value); +} + +/** + * Serialize a typed filter value to its simple-dialect wire form — + * the inverse of {@link parseFilterValue} modulo scalar type + * normalization ('5' → 5, 'true' → true, surrounding whitespace). + * + * Values whose wire form would decode to a *different condition* + * are rejected with a typed error instead of being emitted: + * comma-containing strings (an EQ would decode as IN), empty + * strings and empty arrays (the condition would be dropped). + * + * @param input + */ +export function serializeFilterValue(input: unknown) : string { + if (typeof input === 'string') { + if (input.includes(',')) { + throw AdapterError.featureUnsupported('filters:value:comma'); + } + + if (input.trim().length === 0) { + throw AdapterError.featureUnsupported('filters:value:empty'); + } + + return input; + } + + if ( + typeof input === 'undefined' || + input === null + ) { + return 'null'; + } + + if (typeof input === 'number') { + return `${input}`; + } + + if (typeof input === 'boolean') { + return input ? 'true' : 'false'; + } + + if (Array.isArray(input)) { + if (input.length === 0) { + throw AdapterError.featureUnsupported('filters:value:empty'); + } + + return input + .map((el) => serializeFilterValue(el)) + .join(','); + } + + throw AdapterError.featureUnsupported('filters:value:type'); +}