diff --git a/.changeset/typed-paths-and-transaction-locks.md b/.changeset/typed-paths-and-transaction-locks.md new file mode 100644 index 00000000..eea365df --- /dev/null +++ b/.changeset/typed-paths-and-transaction-locks.md @@ -0,0 +1,12 @@ +--- +"@cleverbrush/server": minor +"@cleverbrush/client": patch +"@cleverbrush/orm": minor +"@cleverbrush/schema": patch +--- + +Add typed endpoint pathname construction and matching, shared with client URL generation and server routing. Route parameters are validated and encoded as individual components. + +Add optional PostgreSQL advisory locks to ORM transactions, acquired before callbacks and released with their transaction. + +Reject non-object inputs to open object schemas with inspectable validation errors, including nested arrays, instead of throwing descriptor errors. diff --git a/libs/client/src/path.test.ts b/libs/client/src/path.test.ts index e9797fcb..071b1895 100644 --- a/libs/client/src/path.test.ts +++ b/libs/client/src/path.test.ts @@ -1,7 +1,15 @@ +import { string } from '@cleverbrush/schema'; +import { route } from '@cleverbrush/server/contract'; import { describe, expect, test, vi } from 'vitest'; import { buildPath } from './path.js'; describe('buildPath', () => { + test('encodes real contract parameters without changing literal separators', () => { + const template = route({ id: string() })`/${p => p.id}/content`; + expect(buildPath('/files', template, { id: 'a/b%2F?#' })).toBe( + '/files/a%2Fb%252F%3F%23/content' + ); + }); test('plain string template — appends to basePath', () => { expect(buildPath('/api/todos', '')).toBe('/api/todos'); expect(buildPath('/api/todos', '/active')).toBe('/api/todos/active'); diff --git a/libs/client/src/path.ts b/libs/client/src/path.ts index 673bd162..e7ef0bb6 100644 --- a/libs/client/src/path.ts +++ b/libs/client/src/path.ts @@ -1,3 +1,4 @@ +import { endpointPath } from '@cleverbrush/server/contract'; /** * Builds the full request URL from endpoint metadata and optional path * parameters. @@ -24,7 +25,12 @@ export function buildPath( } if (typeof pathTemplate.serialize === 'function') { - return basePath + pathTemplate.serialize(params ?? {}); + return typeof (pathTemplate as any).introspect === 'function' + ? endpointPath( + { introspect: () => ({ basePath, pathTemplate }) } as any, + params ?? {} + ) + : basePath + pathTemplate.serialize(params ?? {}); } return basePath; diff --git a/libs/orm/README.md b/libs/orm/README.md index 6d3852f6..3c022096 100644 --- a/libs/orm/README.md +++ b/libs/orm/README.md @@ -521,3 +521,27 @@ Editing a nested object or array marks the column modified; replacing it with an equivalent document does not. `entry(entity).reset()` and `discardChanges()` restore independent copies, so subsequent edits cannot mutate the saved snapshot. JSON validation and encoding also apply to ORM saves and tracked updates. + +### Transaction advisory locks + +Use stable, namespaced keys to serialize operations that can race before a row +exists. PostgreSQL releases these locks when their owning transaction commits or +rolls back. The callback receives the same transaction-bound context as ordinary +transactions; tracked contexts retain tracking. + +```ts +await db.transaction(async tx => { + // Read and write the resource under a transaction-scoped lock. +}, { advisoryLocks: [`resource:${resourceId}`] }); +``` + +Keys must be nonempty strings. Duplicate keys are acquired once, in sorted order. +This prevents inconsistent ordering within one lock set; callers must avoid +inconsistent ordering across nested transactions. The database's configured lock +timeout applies. Unsupported dialects reject advisory locks before the callback +runs; ordinary transactions continue to work. Locks do not replace constraints or +authorization. + +## License + +BSD-3-Clause — see [LICENSE](../../LICENSE). diff --git a/libs/orm/integration/transaction-locks.test.ts b/libs/orm/integration/transaction-locks.test.ts new file mode 100644 index 00000000..1dd26266 --- /dev/null +++ b/libs/orm/integration/transaction-locks.test.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'node:crypto'; +import Knex from 'knex'; +import { afterAll, describe, expect, it } from 'vitest'; +import { createDb } from '../src/index.js'; + +const connection = process.env.QUERY_TEST_DATABASE_URL; +if (!connection) throw new Error('QUERY_TEST_DATABASE_URL is required'); +const knex = Knex({ client: 'pg', connection, pool: { min: 0, max: 5 } }); +const db = createDb(knex, {}); +afterAll(() => knex.destroy()); + +describe('PostgreSQL advisory transactions', () => { + it.each(['commit', 'rollback'])( + 'releases locks after %s and allows unrelated keys', + async outcome => { + const key = `transaction-test:${randomUUID()}`; + let release!: () => void; + let acquired!: () => void; + const ready = new Promise(resolve => { + acquired = resolve; + }); + const hold = new Promise(resolve => { + release = resolve; + }); + const first = db.transaction( + async () => { + acquired(); + await hold; + if (outcome === 'rollback') + throw new Error('rollback fixture'); + }, + { advisoryLocks: [key] } + ); + const finished = first.catch(error => { + if (outcome !== 'rollback') throw error; + }); + try { + await ready; + const locked = await knex.transaction(async trx => { + const result = await trx.raw( + 'SELECT pg_try_advisory_xact_lock(hashtextextended(?, 0)) AS acquired', + [key] + ); + return result.rows[0].acquired; + }); + expect(locked).toBe(false); + expect( + await db.transaction(async () => 'independent', { + advisoryLocks: [key + ':other'] + }) + ).toBe('independent'); + } finally { + release(); + await finished; + } + expect( + await db.transaction(async () => 'reacquired', { + advisoryLocks: [key] + }) + ).toBe('reacquired'); + } + ); + it('serializes callers with the same key', async () => { + const key = `transaction-test:${randomUUID()}`; + let active = 0; + let maximum = 0; + await Promise.all( + Array.from({ length: 4 }, () => + db.transaction( + async scope => { + maximum = Math.max(maximum, ++active); + await scope.knex.raw('SELECT 1'); + active--; + }, + { advisoryLocks: [key] } + ) + ) + ); + expect(maximum).toBe(1); + }); +}); diff --git a/libs/orm/src/dbcontext.ts b/libs/orm/src/dbcontext.ts index 40096b35..fb59ffe3 100644 --- a/libs/orm/src/dbcontext.ts +++ b/libs/orm/src/dbcontext.ts @@ -19,6 +19,37 @@ import { import { type DbSet, makeDbSet } from './dbset.js'; import { PendingChangesError } from './errors.js'; +/** Options applied before a transaction callback runs. */ +export interface TransactionOptions { + /** + * PostgreSQL transaction-scoped advisory locks. Use stable, namespaced keys. + * Keys are deduplicated and sorted to give overlapping batches a consistent + * acquisition order. Locks remain held until the owning transaction ends; + * they do not replace row constraints or authorization. + */ + readonly advisoryLocks?: readonly string[]; +} + +/** Acquire locks using parameter bindings on the transaction's connection. */ +async function acquireTransactionLocks( + trx: Knex.Transaction, + options?: TransactionOptions +): Promise { + const keys = options?.advisoryLocks ?? []; + if (!keys.length) return; + if (!['pg', 'postgres', 'postgresql'].includes(trx.client.config.client)) { + throw new Error('Advisory transaction locks require PostgreSQL.'); + } + if (keys.some(key => typeof key !== 'string' || key.length === 0)) { + throw new TypeError('Advisory lock keys must be nonempty strings.'); + } + for (const key of [...new Set(keys)].sort()) { + await trx.raw('SELECT pg_advisory_xact_lock(hashtextextended(?, 0))', [ + key + ]); + } +} + /** * The map of entity name → entity definition passed to {@link createDb}. * @@ -44,7 +75,10 @@ export type DbContext = { * Run `callback` inside a Knex transaction. The callback receives a new * `DbContext` whose `DbSet`s are bound to the transaction. */ - transaction(callback: (db: DbContext) => Promise): Promise; + transaction( + callback: (db: DbContext) => Promise, + options?: TransactionOptions + ): Promise; /** * Return a new `DbContext` whose `DbSet`s are bound to `trx`. Useful @@ -148,9 +182,11 @@ function buildContext( return buildContext(trx as unknown as Knex, entities); }, async transaction( - callback: (db: DbContext) => Promise + callback: (db: DbContext) => Promise, + options?: TransactionOptions ): Promise { return knex.transaction(async (trx: Knex.Transaction) => { + await acquireTransactionLocks(trx, options); return callback( buildContext(trx as unknown as Knex, entities) ); @@ -198,9 +234,11 @@ function buildTrackedContext( }, async transaction( - callback: (db: DbContext) => Promise + callback: (db: DbContext) => Promise, + options?: TransactionOptions ): Promise { return knex.transaction(async (trx: Knex.Transaction) => { + await acquireTransactionLocks(trx, options); return callback( buildTrackedContext( trx as unknown as Knex, diff --git a/libs/orm/src/index.ts b/libs/orm/src/index.ts index ca290237..da222ed3 100644 --- a/libs/orm/src/index.ts +++ b/libs/orm/src/index.ts @@ -15,7 +15,8 @@ export type { export type { DbContext, EntityMap, - TrackedDbContext + TrackedDbContext, + TransactionOptions } from './dbcontext.js'; export { createDb } from './dbcontext.js'; export type { diff --git a/libs/orm/src/transaction-locks.test.ts b/libs/orm/src/transaction-locks.test.ts new file mode 100644 index 00000000..b1463f8e --- /dev/null +++ b/libs/orm/src/transaction-locks.test.ts @@ -0,0 +1,61 @@ +import type { Knex } from 'knex'; +import { describe, expect, it, vi } from 'vitest'; +import { createDb } from './index.js'; + +function database(client = 'pg', tracking = false) { + const raw = vi.fn().mockResolvedValue(undefined); + const transaction = { client: { config: { client } }, raw }; + const knex = { + transaction: vi.fn(async callback => callback(transaction)) + } as unknown as Knex; + return { db: createDb(knex, {}, { tracking }), raw, transaction }; +} + +describe('advisory transaction options', () => { + it.each([false, true])( + 'locks once in stable order before callback (tracking=%s)', + async tracking => { + const { db, raw, transaction } = database('pg', tracking); + const value = await db.transaction( + async scope => { + expect(scope.knex).toBe(transaction); + expect(raw.mock.calls.map(call => call[1])).toEqual([ + ['asset:a'], + ['asset:z'] + ]); + return 'done'; + }, + { advisoryLocks: ['asset:z', 'asset:a', 'asset:z'] } + ); + expect(value).toBe('done'); + } + ); + it('preserves transactions without locking on other dialects', async () => { + const { db, raw } = database('sqlite3'); + expect(await db.transaction(async () => 7)).toBe(7); + expect(raw).not.toHaveBeenCalled(); + }); + it('rejects unsupported dialects and invalid keys before running the callback', async () => { + const callback = vi.fn(); + await expect( + database('sqlite3').db.transaction(callback, { + advisoryLocks: ['one'] + }) + ).rejects.toThrow('PostgreSQL'); + for (const keys of [[''], [7] as any]) { + await expect( + database().db.transaction(callback, { advisoryLocks: keys }) + ).rejects.toThrow('nonempty strings'); + } + expect(callback).not.toHaveBeenCalled(); + }); + it('propagates lock errors without executing work', async () => { + const { db, raw } = database(); + raw.mockRejectedValue(new Error('lock timeout')); + const callback = vi.fn(); + await expect( + db.transaction(callback, { advisoryLocks: ['one'] }) + ).rejects.toThrow('lock timeout'); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/schema/src/builders/ObjectSchemaBuilder.boundaries.test.ts b/libs/schema/src/builders/ObjectSchemaBuilder.boundaries.test.ts new file mode 100644 index 00000000..2608e7b7 --- /dev/null +++ b/libs/schema/src/builders/ObjectSchemaBuilder.boundaries.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { array, object, string } from '../index.js'; + +describe('open object boundaries', () => { + it.each([0, 'text', true, [], [1], null, undefined])( + 'rejects %j with inspectable errors', + async value => { + const schema = object({}).acceptUnknownProps(); + const result = schema.safeParse(value); + expect(result.valid).toBe(false); + const full = schema.validate(value as any, { + doNotStopOnFirstError: true + }); + expect(full.valid).toBe(false); + expect(full.getErrorsFor().isValid).toBe(false); + expect((await schema.validateAsync(value as any)).valid).toBe( + false + ); + const list = array(schema).validate([value] as any); + expect(list.valid).toBe(false); + expect(list.getNestedErrors()[0].getErrorsFor().isValid).toBe( + false + ); + expect( + (await array(schema).validateAsync([value] as any)).valid + ).toBe(false); + } + ); + it('keeps nested extensions and empty arrays without mutating the input', () => { + const schema = object({ + rows: array( + object({ + data: object({}).acceptUnknownProps(), + items: array(string()) + }) + ) + }); + const value = { + rows: [ + { + data: { extension: { empty: [], nullable: null } }, + items: [] + } + ] + }; + const result = schema.parse(value); + expect(result).toEqual(value); + expect(Object.getOwnPropertySymbols(result.rows[0].items)).toEqual([]); + expect(Object.getOwnPropertySymbols(value.rows[0].items)).toEqual([]); + }); + it('preserves optional, nullable and preprocessor behavior', () => { + expect(object({}).optional().safeParse(undefined).valid).toBe(true); + expect(object({}).nullable().safeParse(null).valid).toBe(true); + expect( + object({ name: string() }) + .addPreprocessor(value => + typeof value === 'string' ? { name: value } : value + ) + .parse('Ada' as any) + ).toEqual({ name: 'Ada' }); + }); +}); diff --git a/libs/schema/src/builders/ObjectSchemaBuilder.ts b/libs/schema/src/builders/ObjectSchemaBuilder.ts index 286ebbcb..34f1aeeb 100644 --- a/libs/schema/src/builders/ObjectSchemaBuilder.ts +++ b/libs/schema/src/builders/ObjectSchemaBuilder.ts @@ -872,7 +872,11 @@ export class ObjectSchemaBuilder< }; } - if (typeof objToValidate !== 'object' || objToValidate === null) { + if ( + typeof objToValidate !== 'object' || + objToValidate === null || + Array.isArray(objToValidate) + ) { // Only add "must be an object" when there is no prior error (e.g. // a required-violation error already pushed by preValidateAsync). if (errors.length === 0) { @@ -1356,7 +1360,7 @@ export class ObjectSchemaBuilder< } } as any; } - } else if (typeof object === 'object') { + } else if (typeof object === 'object' && !Array.isArray(object)) { const propKeys = this.#propKeys; // Check for unknown properties @@ -2640,10 +2644,6 @@ export class ObjectSchemaBuilder< const propsNames = Object.keys(introspected.properties); - if (propsNames.length === 0) { - return {} as any; - } - if (typeof selector !== 'function') { selector = o => o; } diff --git a/libs/schema/src/builders/PropertyDescriptors.test.ts b/libs/schema/src/builders/PropertyDescriptors.test.ts index ecf9f02e..62abeaed 100644 --- a/libs/schema/src/builders/PropertyDescriptors.test.ts +++ b/libs/schema/src/builders/PropertyDescriptors.test.ts @@ -15,13 +15,17 @@ test('Throws with not ObjectSchemaBuilder instance', async () => { expect(() => (object as any).getPropertiesFor(number())).toThrow(); }); -test('Returns empty object for empty schema', async () => { +test('Returns a root descriptor without children for an empty schema', async () => { const schema = object(); const tree = object.getPropertiesFor(schema); expect(tree).not.toBeNull(); expect(tree).not.toBeUndefined(); - expect(tree).toEqual({}); + expect(Object.keys(tree)).toEqual([]); + const descriptor = tree[SYMBOL_SCHEMA_PROPERTY_DESCRIPTOR]; + expect(descriptor).toBeDefined(); + const value = {}; + expect(descriptor.getValue(value)).toMatchObject({ success: true, value }); }); test('Properties exists', async () => { diff --git a/libs/server/README.md b/libs/server/README.md index 8cb7fef2..5f196f0b 100644 --- a/libs/server/README.md +++ b/libs/server/README.md @@ -990,6 +990,27 @@ chunks and restore response hooks on failure. See [security guidance](../../SECURITY.md) and the [v4.x-to-v5 migration guide](../../docs/MIGRATION-v5.md). + + +### Endpoint path helpers + +`endpointPath(endpoint, params)` constructs an encoded pathname from a contract. +`matchEndpointPath(endpoint, pathname)` returns validated parameters or `undefined`. +Both are exported from `@cleverbrush/server/contract` for browser use and from the +main package. Matching checks paths only, not methods or authorization. Origins, +query strings and fragments are intentionally separate. + +```ts +import { endpointPath, matchEndpointPath } from '@cleverbrush/server/contract'; + +const path = endpointPath(api.files.content, { id: 'report 1' }); +const params = matchEndpointPath(api.files.content, path); +``` + +Parameters are URI-component encoded and decoded once. Invalid values throw +during construction; mismatches, malformed encoding and invalid parameter values +return `undefined` when matching. One trailing slash is accepted. + ## License BSD-3-Clause — see [LICENSE](../../LICENSE). diff --git a/libs/server/src/EndpointPath.test-d.ts b/libs/server/src/EndpointPath.test-d.ts new file mode 100644 index 00000000..0cb4746d --- /dev/null +++ b/libs/server/src/EndpointPath.test-d.ts @@ -0,0 +1,28 @@ +import { number, string } from '@cleverbrush/schema'; +import { expectTypeOf, test } from 'vitest'; +import { + endpoint, + endpointPath, + matchEndpointPath, + route +} from './contract.js'; + +test('path arguments and matched parameters follow the contract', () => { + const get = endpoint.resource('').get( + route({ + id: number().coerce(), + name: string() + })`/items/${p => p.id}/${p => p.name}` + ); + endpointPath(get, { id: 1, name: 'item' }); + expectTypeOf(matchEndpointPath(get, '/items/1/item')).toEqualTypeOf< + { id: number; name: string } | undefined + >(); + // @ts-expect-error path parameters are required + endpointPath(get); + // @ts-expect-error a renamed property cannot silently construct a stale URL + endpointPath(get, { itemId: 1, name: 'item' }); + // @ts-expect-error input types follow the contract + endpointPath(get, { id: '1', name: 'item' }); + endpointPath(endpoint.get('/health')); +}); diff --git a/libs/server/src/EndpointPath.test.ts b/libs/server/src/EndpointPath.test.ts new file mode 100644 index 00000000..c92540f9 --- /dev/null +++ b/libs/server/src/EndpointPath.test.ts @@ -0,0 +1,120 @@ +import { number, object, string } from '@cleverbrush/schema'; +import { describe, expect, it } from 'vitest'; +import { ActionResult } from './ActionResult.js'; +import { + endpoint, + endpointPath, + matchEndpointPath, + route +} from './contract.js'; +import { createServer } from './Server.js'; + +const content = endpoint + .resource('/api/files') + .get(route({ id: string().minLength(1) })`/${p => p.id}/content`); + +describe('endpoint paths', () => { + it.each([ + 'file-1', + 'report one', + 'a/b', + 'a?b#c', + '100%', + 'a%2Fb', + '日本語' + ])('round trips parameter %s', id => { + const path = endpointPath(content, { id }); + expect(path).toBe(`/api/files/${encodeURIComponent(id)}/content`); + expect(matchEndpointPath(content, path)).toEqual({ id }); + expect(matchEndpointPath(content, path + '/')).toEqual({ id }); + }); + it('supports static paths, resources and nested/coerced parameters', () => { + expect(endpointPath(endpoint.get('/health'))).toBe('/health'); + expect(endpointPath(endpoint.resource('/health').get('/'))).toBe( + '/health' + ); + expect(matchEndpointPath(endpoint.get('/health'), '/health/')).toEqual( + {} + ); + const nested = endpoint.resource('').get( + route({ + owner: object({ id: number().coerce().isInteger().min(1) }) + })`/owners/${p => p.owner.id}` + ); + expect(endpointPath(nested, { owner: { id: 7 } })).toBe('/owners/7'); + expect(matchEndpointPath(nested, '/owners/7')).toEqual({ + owner: { id: 7 } + }); + expect(matchEndpointPath(nested, '/owners/0')).toBeUndefined(); + expect(matchEndpointPath(nested, '/owners/seven')).toBeUndefined(); + expect(() => endpointPath(nested, { owner: { id: 0 } })).toThrow( + 'Invalid path parameter' + ); + }); + it.each([ + '/other/a/content', + '/api/files/%XX/content', + '/api/files/a/content?q=1', + '/api/files/a/content#fragment', + 'https://example.com/api/files/a/content', + '//api/files/a/content' + ])('rejects invalid pathname %s', path => { + expect(matchEndpointPath(content, path)).toBeUndefined(); + }); + it('matches encoded literals without decoding parameter escapes twice', () => { + const item = endpoint + .resource('/café') + .get(route({ id: string() })`/日本/${p => p.id}`); + expect( + matchEndpointPath(item, '/caf%C3%A9/%E6%97%A5%E6%9C%AC/a%252Fb') + ).toEqual({ id: 'a%2Fb' }); + expect(matchEndpointPath(item, '/caf%c3%a9/日本/item')).toEqual({ + id: 'item' + }); + }); + it('retains empty parameters permitted by their schema', () => { + const item = endpoint + .resource('/items') + .get(route({ id: string() })`/${p => p.id}/content`); + expect(matchEndpointPath(item, '/items//content')).toEqual({ id: '' }); + }); + it('preserves multi-segment route parameters', () => { + expect(matchEndpointPath(content, '/api/files/a/b/content')).toEqual({ + id: 'a/b' + }); + }); + it('validates required parameters and escapes literal regex syntax', () => { + expect(() => endpointPath(content, {} as any)).toThrow( + 'Missing path parameter' + ); + expect(() => endpointPath(content, { id: '' })).toThrow( + 'Invalid path parameter' + ); + const literal = endpoint + .resource('') + .get(route({ id: string() })`/files/v1.0/${p => p.id}`); + expect(matchEndpointPath(literal, '/files/v1X0/a')).toBeUndefined(); + expect(matchEndpointPath(literal, '/files/v1.0/a')).toEqual({ + id: 'a' + }); + }); + it('uses the same encoded parameters in the HTTP router', async () => { + const server = await createServer() + .handle(content, ({ params }) => ActionResult.ok(params)) + .listen(0, '127.0.0.1'); + try { + const address = server.address; + const port = + typeof address === 'object' && address ? address.port : 0; + for (const id of ['report one', 'a/b', 'a%2Fb', 'a?b#c']) { + const response = await fetch( + `http://127.0.0.1:${port}${endpointPath(content, { id })}` + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id }); + } + } finally { + await server.close(); + } + }); +}); diff --git a/libs/server/src/EndpointPath.ts b/libs/server/src/EndpointPath.ts new file mode 100644 index 00000000..dfa7eec0 --- /dev/null +++ b/libs/server/src/EndpointPath.ts @@ -0,0 +1,149 @@ +import type { EndpointBuilder, EndpointMetadata } from './Endpoint.js'; + +type AnyEndpoint = EndpointBuilder< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +>; +/** The path-parameter type declared by an endpoint. */ +export type EndpointParams = + E extends EndpointBuilder< + infer P, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + ? P + : never; +type PathMetadata = Pick; + +const trimTrailingSlash = (path: string) => + path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +const escapeRegex = (text: string) => + text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** Match literal path text before decoding captured parameters. */ +function literalPattern(literal: string): string { + return Array.from(decodeURI(literal), character => { + const encoded = encodeURI(character); + return encoded === character + ? escapeRegex(character) + : `(?:${escapeRegex(character)}|${encoded.replace(/[A-F]/g, c => `[${c}${c.toLowerCase()}]`)})`; + }).join(''); +} + +/** + * Build a relative pathname from an endpoint's contract. Parameters are validated + * and encoded individually, so reserved characters cannot introduce URL syntax. + * Query strings and origins belong to the caller. Throws for invalid parameters. + * @example endpointPath(api.files.content, { id: 'report 1' }) + */ +export function endpointPath( + endpoint: E, + ...args: keyof EndpointParams extends never + ? [params?: EndpointParams] + : [params: EndpointParams] +): string { + const { basePath, pathTemplate } = endpoint.introspect(); + if (typeof pathTemplate === 'string') + return pathTemplate === '/' ? basePath || '/' : basePath + pathTemplate; + const { literals, segments } = pathTemplate.introspect().templateDefinition; + let path = basePath; + for (let index = 0; index < segments.length; index++) { + const segment = segments[index]; + const value = segment.descriptor.getValue(args[0] ?? {}); + if (!value.success || value.value == null) + throw new TypeError(`Missing path parameter: ${segment.path}`); + const checked = segment.schema.safeParse(value.value); + if (!checked.valid) + throw new TypeError(`Invalid path parameter: ${segment.path}`); + path += literals[index] + encodeURIComponent(String(checked.object)); + } + path += literals[segments.length] ?? ''; + return trimTrailingSlash(path) || '/'; +} + +/** + * Match a pathname against one endpoint and return validated path parameters. + * Returns undefined for a different route, invalid encoding/parameters or an + * absolute URL/query/fragment. This does not check HTTP method or authorization. + * One optional trailing slash is accepted, matching server routing. Parameters + * may contain slashes when their schema permits them, as in route templates. + */ +export function matchEndpointPath( + endpoint: E, + pathname: string +): EndpointParams | undefined { + return matchRoutePath(endpoint.introspect(), pathname) as + | EndpointParams + | undefined; +} + +/** @internal Shared by HTTP/WebSocket routing so matching has one implementation. */ +export function matchRoutePath( + meta: PathMetadata, + pathname: string +): Record | undefined { + if ( + !pathname.startsWith('/') || + pathname.startsWith('//') || + /[?#]/.test(pathname) + ) + return undefined; + try { + // Validate percent escapes without decoding dynamic values twice. + decodeURI(pathname); + const { basePath, pathTemplate } = meta; + if (typeof pathTemplate === 'string') { + const expected = + pathTemplate === '/' + ? basePath || '/' + : basePath + pathTemplate; + return trimTrailingSlash(decodeURI(pathname)) === + trimTrailingSlash(decodeURI(expected)) + ? {} + : undefined; + } + const { literals, segments } = + pathTemplate.introspect().templateDefinition; + const pattern = + literalPattern(basePath + (literals[0] ?? '')) + + segments + .map( + (_, index) => + '(.*?)' + literalPattern(literals[index + 1] ?? '') + ) + .join(''); + const matched = new RegExp( + '^' + trimTrailingSlash(pattern) + '/?$' + ).exec(pathname); + if (!matched) return undefined; + const params: Record = {}; + for (let index = 0; index < segments.length; index++) { + const segment = segments[index]; + const result = segment.schema.safeParse( + decodeURIComponent(matched[index + 1]) + ); + if (!result.valid) return undefined; + segment.descriptor.setValue(params, result.object, { + createMissingStructure: true + }); + } + return params; + } catch { + return undefined; + } +} diff --git a/libs/server/src/Router.ts b/libs/server/src/Router.ts index be12f1b7..8f4559bf 100644 --- a/libs/server/src/Router.ts +++ b/libs/server/src/Router.ts @@ -1,4 +1,5 @@ import type { ParseStringSchemaBuilder } from '@cleverbrush/schema'; +import { matchRoutePath } from './EndpointPath.js'; import type { EndpointRegistration, RouteMatch, @@ -256,7 +257,7 @@ export class Router { // Try exact method match first const methodRoutes = this.#routes.get(upperMethod); if (methodRoutes) { - const result = this.#findFirstMatch(methodRoutes, normalized); + const result = this.#findFirstMatch(methodRoutes, normalized, url); if (result) { return { match: result, methodNotAllowed: false }; } @@ -266,7 +267,7 @@ export class Router { const allowedMethods: string[] = []; for (const [m, routes] of this.#routes) { if (m === upperMethod) continue; - if (this.#findFirstMatch(routes, normalized)) { + if (this.#findFirstMatch(routes, normalized, url)) { allowedMethods.push(m); } } @@ -281,16 +282,17 @@ export class Router { /** Returns the first successful match from an indexed route collection. */ #findFirstMatch( collection: RouteCollection, - normalizedUrl: string + normalizedUrl: string, + rawUrl: string ): RegisteredRouteMatch | null { const exactRoute = collection.exactRoutes.get(normalizedUrl); if (exactRoute) { - const match = this.#tryMatch(exactRoute, normalizedUrl); + const match = this.#tryMatch(exactRoute, normalizedUrl, rawUrl); if (match) return match; } for (const route of collection.dynamicRoutes) { - const match = this.#tryMatch(route, normalizedUrl); + const match = this.#tryMatch(route, normalizedUrl, rawUrl); if (match) return match; } @@ -305,7 +307,8 @@ export class Router { #tryMatch( route: RegisteredRoute, - normalizedUrl: string + normalizedUrl: string, + rawUrl: string ): RegisteredRouteMatch | null { const { basePath, routePath } = route; @@ -320,11 +323,14 @@ export class Router { if (isParseStringSchema(routePath)) { // Dynamic route: validate remainder via parseString schema - const result = routePath.validate(remainder); - if (result.valid) { + const result = matchRoutePath( + { basePath, pathTemplate: routePath }, + rawUrl + ); + if (result !== undefined) { return { registration: route.registration, - parsedPath: result.object as Record + parsedPath: result as Record }; } return null; @@ -378,6 +384,6 @@ export class Router { } this.finalize(); - return this.#findFirstMatch(this.#subscriptionRoutes, normalized); + return this.#findFirstMatch(this.#subscriptionRoutes, normalized, url); } } diff --git a/libs/server/src/contract.ts b/libs/server/src/contract.ts index 64f6d252..a4c83826 100644 --- a/libs/server/src/contract.ts +++ b/libs/server/src/contract.ts @@ -310,6 +310,11 @@ export function omitGroups( return Object.freeze(result); } +export { + type EndpointParams, + endpointPath, + matchEndpointPath +} from './EndpointPath.js'; export { file, type UploadConfiguration, diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index 226cf89a..ebd326c6 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -56,6 +56,11 @@ export type { EndpointOptions, IdempotencyScope } from './EndpointOptions.js'; +export { + type EndpointParams, + endpointPath, + matchEndpointPath +} from './EndpointPath.js'; export { ErrorMap, type ErrorResponse, diff --git a/libs/server/tests/Router.test.ts b/libs/server/tests/Router.test.ts index a48df760..df634cad 100644 --- a/libs/server/tests/Router.test.ts +++ b/libs/server/tests/Router.test.ts @@ -103,7 +103,6 @@ describe('Router', () => { object({ id: string() }), $t => $t`/${t => t.id}/question` ); - const genericValidate = vi.spyOn(GenericPath, 'validate'); const router = new Router(); const generic = makeRegistration('GET', '/sessions', GenericPath); const question = makeRegistration('GET', '/sessions', QuestionPath); @@ -114,7 +113,6 @@ describe('Router', () => { expect(result.match?.registration).toBe(question); expect(result.match?.parsedPath).toEqual({ id: '984' }); - expect(genericValidate).not.toHaveBeenCalled(); }); it('falls back when a more-specific route fails validation', () => { @@ -126,8 +124,6 @@ describe('Router', () => { object({ id: number().coerce() }), $t => $t`/${t => t.id}/question` ); - const specificValidate = vi.spyOn(NumericQuestionPath, 'validate'); - const genericValidate = vi.spyOn(GenericPath, 'validate'); const router = new Router(); const generic = makeRegistration('GET', '/sessions', GenericPath); const numericQuestion = makeRegistration( @@ -144,8 +140,6 @@ describe('Router', () => { expect(result.match?.parsedPath).toEqual({ id: 'not-a-number/question' }); - expect(specificValidate).toHaveBeenCalledOnce(); - expect(genericValidate).toHaveBeenCalledOnce(); }); it('re-finalizes after a route is added following a match', () => { @@ -201,7 +195,6 @@ describe('Router', () => { object({ id: string() }), $t => $t`/${t => t.id}` ); - const genericValidate = vi.spyOn(GenericPath, 'validate'); const router = new Router(); const generic = makeRegistration('GET', '/sessions', GenericPath); const exact = makeRegistration('GET', '/sessions/984/question'); @@ -212,7 +205,6 @@ describe('Router', () => { expect(result.match?.registration).toBe(exact); expect(result.match?.parsedPath).toBeNull(); - expect(genericValidate).not.toHaveBeenCalled(); }); it('indexes zero-interpolation templates as exact routes', () => { @@ -221,7 +213,6 @@ describe('Router', () => { $t => $t`/${t => t.id}` ); const StaticPath = parseString(object({}), $t => $t`/health`); - const genericValidate = vi.spyOn(GenericPath, 'validate'); const router = new Router(); const generic = makeRegistration('GET', '', GenericPath); const exact = makeRegistration('GET', '', StaticPath); @@ -232,7 +223,6 @@ describe('Router', () => { expect(result.match?.registration).toBe(exact); expect(result.match?.parsedPath).toEqual({}); - expect(genericValidate).not.toHaveBeenCalled(); }); it('prefers fewer dynamic segments when literal counts are equal', () => { @@ -433,7 +423,6 @@ describe('Router', () => { object({ id: string() }), $t => $t`/${t => t.id}/question` ); - const genericValidate = vi.spyOn(GenericPath, 'validate'); const router = new Router(); const generic = makeSubscriptionRegistration( '/ws/sessions', @@ -450,7 +439,6 @@ describe('Router', () => { expect(result?.registration).toBe(question); expect(result?.parsedPath).toEqual({ id: '984' }); - expect(genericValidate).not.toHaveBeenCalled(); }); it('uses registration order for equal-specificity subscriptions', () => { diff --git a/vitest.queries.config.mts b/vitest.queries.config.mts index 6339951b..7169b2f5 100644 --- a/vitest.queries.config.mts +++ b/vitest.queries.config.mts @@ -2,7 +2,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['libs/knex-schema/integration/**/*.test.ts'], + include: [ + 'libs/knex-schema/integration/**/*.test.ts', + 'libs/orm/integration/**/*.test.ts' + ], testTimeout: 15000, hookTimeout: 30000 }