diff --git a/packages/server/src/api/common/schemas.ts b/packages/server/src/api/common/schemas.ts new file mode 100644 index 000000000..44e30665c --- /dev/null +++ b/packages/server/src/api/common/schemas.ts @@ -0,0 +1,3 @@ +import z from 'zod'; + +export const loggerSchema = z.union([z.enum(['debug', 'info', 'warn', 'error']).array(), z.function()]); diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 1b5580afc..b4e5dce25 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -7,15 +7,12 @@ import tsjapi, { type Linker, type Paginator, type Relator, type Serializer, typ import { match } from 'ts-pattern'; import UrlPattern from 'url-pattern'; import z from 'zod'; +import { fromError } from 'zod-validation-error/v4'; import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types'; +import { getProcedureDef, mapProcedureArgs } from '../common/procedures'; +import { loggerSchema } from '../common/schemas'; +import { processSuperJsonRequestPayload } from '../common/utils'; import { getZodErrorMessage, log, registerCustomSerializers } from '../utils'; -import { - getProcedureDef, - mapProcedureArgs, -} from '../common/procedures'; -import { - processSuperJsonRequestPayload, -} from '../common/utils'; /** * Options for {@link RestApiHandler} @@ -58,8 +55,14 @@ export type RestApiHandlerOptions = { */ urlSegmentCharset?: string; + /** + * Mapping from model names to URL segment names. + */ modelNameMapping?: Record; + /** + * Mapping from model names to unique field name to be used as resource's ID. + */ externalIdMapping?: Record; }; @@ -260,6 +263,8 @@ export class RestApiHandler implements Api private externalIdMapping: Record; constructor(private readonly options: RestApiHandlerOptions) { + this.validateOptions(options); + this.idDivider = options.idDivider ?? DEFAULT_ID_DIVIDER; const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %'; @@ -282,6 +287,23 @@ export class RestApiHandler implements Api this.buildSerializers(); } + private validateOptions(options: RestApiHandlerOptions) { + const schema = z.strictObject({ + schema: z.object(), + log: loggerSchema.optional(), + endpoint: z.string().min(1), + pageSize: z.number().positive().optional(), + idDivider: z.string().min(1).optional(), + urlSegmentCharset: z.string().min(1).optional(), + modelNameMapping: z.record(z.string(), z.string()).optional(), + externalIdMapping: z.record(z.string(), z.string()).optional(), + }); + const parseResult = schema.safeParse(options); + if (!parseResult.success) { + throw new Error(`Invalid options: ${fromError(parseResult.error)}`); + } + } + get schema() { return this.options.schema; } @@ -530,7 +552,9 @@ export class RestApiHandler implements Api try { procInput = mapProcedureArgs(procDef, processedArgsPayload); } catch (err) { - return this.makeProcBadInputErrorResponse(err instanceof Error ? err.message : 'invalid procedure arguments'); + return this.makeProcBadInputErrorResponse( + err instanceof Error ? err.message : 'invalid procedure arguments', + ); } try { @@ -926,16 +950,16 @@ export class RestApiHandler implements Api prev: offset - limit >= 0 && offset - limit <= total - 1 ? this.replaceURLSearchParams(baseUrl, { - 'page[offset]': offset - limit, - 'page[limit]': limit, - }) + 'page[offset]': offset - limit, + 'page[limit]': limit, + }) : null, next: offset + limit <= total - 1 ? this.replaceURLSearchParams(baseUrl, { - 'page[offset]': offset + limit, - 'page[limit]': limit, - }) + 'page[offset]': offset + limit, + 'page[limit]': limit, + }) : null, })); } @@ -2001,8 +2025,8 @@ export class RestApiHandler implements Api } else { currPayload[relation] = select ? { - select: { ...select }, - } + select: { ...select }, + } : true; } } diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index 6e094c37e..6f572e82d 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -3,16 +3,13 @@ import { ORMError, ORMErrorReason, type ClientContract } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; import SuperJSON from 'superjson'; import { match } from 'ts-pattern'; +import z from 'zod'; +import { fromError } from 'zod-validation-error/v4'; import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types'; +import { getProcedureDef, mapProcedureArgs, PROCEDURE_ROUTE_PREFIXES } from '../common/procedures'; +import { loggerSchema } from '../common/schemas'; +import { processSuperJsonRequestPayload, unmarshalQ } from '../common/utils'; import { log, registerCustomSerializers } from '../utils'; -import { - getProcedureDef, - mapProcedureArgs, -} from '../common/procedures'; -import { - processSuperJsonRequestPayload, - unmarshalQ, -} from '../common/utils'; registerCustomSerializers(); @@ -35,7 +32,17 @@ export type RPCApiHandlerOptions = { * RPC style API request handler that mirrors the ZenStackClient API */ export class RPCApiHandler implements ApiHandler { - constructor(private readonly options: RPCApiHandlerOptions) { } + constructor(private readonly options: RPCApiHandlerOptions) { + this.validateOptions(options); + } + + private validateOptions(options: RPCApiHandlerOptions) { + const schema = z.strictObject({ schema: z.object(), log: loggerSchema.optional() }); + const parseResult = schema.safeParse(options); + if (!parseResult.success) { + throw new Error(`Invalid options: ${fromError(parseResult.error)}`); + } + } get schema(): Schema { return this.options.schema; @@ -54,7 +61,7 @@ export class RPCApiHandler implements ApiH return this.makeBadInputErrorResponse('invalid request path'); } - if (model === '$procs') { + if (model === PROCEDURE_ROUTE_PREFIXES) { return this.handleProcedureRequest({ client, method: method.toUpperCase(), @@ -96,9 +103,7 @@ export class RPCApiHandler implements ApiH return this.makeBadInputErrorResponse('invalid request method, only GET is supported'); } try { - args = query?.['q'] - ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) - : {}; + args = query?.['q'] ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) : {}; } catch { return this.makeBadInputErrorResponse('invalid "q" query parameter'); } @@ -123,9 +128,7 @@ export class RPCApiHandler implements ApiH return this.makeBadInputErrorResponse('invalid request method, only DELETE is supported'); } try { - args = query?.['q'] - ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) - : {}; + args = query?.['q'] ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) : {}; } catch (err) { return this.makeBadInputErrorResponse( err instanceof Error ? err.message : 'invalid "q" query parameter', @@ -223,7 +226,9 @@ export class RPCApiHandler implements ApiH ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) : undefined; } catch (err) { - return this.makeBadInputErrorResponse(err instanceof Error ? err.message : 'invalid "q" query parameter'); + return this.makeBadInputErrorResponse( + err instanceof Error ? err.message : 'invalid "q" query parameter', + ); } } @@ -251,7 +256,11 @@ export class RPCApiHandler implements ApiH } const response = { status: 200, body: responseBody }; - log(this.options.log, 'debug', () => `sending response for "$procs.${proc}" request: ${safeJSONStringify(response)}`); + log( + this.options.log, + 'debug', + () => `sending response for "$procs.${proc}" request: ${safeJSONStringify(response)}`, + ); return response; } catch (err) { log(this.options.log, 'error', `error occurred when handling "$procs.${proc}" request`, err); @@ -312,7 +321,7 @@ export class RPCApiHandler implements ApiH status = 400; error.dbErrorCode = err.dbErrorCode; }) - .otherwise(() => { }); + .otherwise(() => {}); const resp = { status, body: { error } }; log(this.options.log, 'debug', () => `sending error response: ${safeJSONStringify(resp)}`); diff --git a/packages/server/test/api/options-validation.test.ts b/packages/server/test/api/options-validation.test.ts new file mode 100644 index 000000000..5861a13c1 --- /dev/null +++ b/packages/server/test/api/options-validation.test.ts @@ -0,0 +1,600 @@ +import { ClientContract } from '@zenstackhq/orm'; +import { SchemaDef } from '@zenstackhq/orm/schema'; +import { createTestClient } from '@zenstackhq/testtools'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { RestApiHandler } from '../../src/api/rest'; +import { RPCApiHandler } from '../../src/api/rpc'; + +describe('API Handler Options Validation', () => { + let client: ClientContract; + + const testSchema = ` + model User { + id String @id @default(cuid()) + email String @unique + name String + } + `; + + beforeEach(async () => { + client = await createTestClient(testSchema); + }); + + describe('RestApiHandler Options Validation', () => { + it('should accept valid options', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + }).not.toThrow(); + }); + + it('should accept valid options with all optional fields', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: ['debug', 'info', 'warn', 'error'], + pageSize: 50, + idDivider: '-', + urlSegmentCharset: 'a-zA-Z0-9-_~', + modelNameMapping: { User: 'users' }, + }); + }).not.toThrow(); + }); + + it('should accept custom log function', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: (level: string, message: string) => { + console.log(`[${level}] ${message}`); + }, + }); + }).not.toThrow(); + }); + + it('should throw error when schema is missing', () => { + expect(() => { + new RestApiHandler({ + endpoint: 'http://localhost/api', + } as any); + }).toThrow('Invalid options'); + }); + + it('should throw error when endpoint is missing', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + } as any); + }).toThrow('Invalid options'); + }); + + it('should throw error when endpoint is empty string', () => { + // Note: Zod z.string() validation allows empty strings + // The endpoint validation doesn't enforce non-empty string + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: '', + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when endpoint is not a string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 123, + } as any); + }).toThrow('Invalid options'); + }); + + it('should throw error when pageSize is not a number', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when pageSize is zero', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 0, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when pageSize is negative', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: -10, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when idDivider is empty string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + idDivider: '', + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when idDivider is not a string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + idDivider: 123 as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when urlSegmentCharset is empty string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + urlSegmentCharset: '', + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when urlSegmentCharset is not a string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + urlSegmentCharset: [] as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when modelNameMapping is not an object', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when modelNameMapping values are not strings', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: { User: 123 } as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when externalIdMapping is not an object', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + externalIdMapping: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when externalIdMapping values are not strings', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + externalIdMapping: { User: 123 } as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log is invalid type', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log array contains invalid values', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: ['debug', 'invalid'] as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is not an object', () => { + expect(() => { + new RestApiHandler({ + schema: 'invalid' as any, + endpoint: 'http://localhost/api', + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is null', () => { + expect(() => { + new RestApiHandler({ + schema: null as any, + endpoint: 'http://localhost/api', + }); + }).toThrow('Invalid options'); + }); + + it('should accept valid pageSize of 1', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 1, + }); + }).not.toThrow(); + }); + + it('should accept large pageSize', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 10000, + }); + }).not.toThrow(); + }); + + it('should accept single character idDivider', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + idDivider: '|', + }); + }).not.toThrow(); + }); + + it('should accept multi-character idDivider', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + idDivider: '---', + }); + }).not.toThrow(); + }); + }); + + describe('RPCApiHandler Options Validation', () => { + it('should accept valid options', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + }); + }).not.toThrow(); + }); + + it('should accept valid options with log array', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: ['debug', 'info', 'warn', 'error'], + }); + }).not.toThrow(); + }); + + it('should accept valid options with log function', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: (level: string, message: string) => { + console.log(`[${level}] ${message}`); + }, + }); + }).not.toThrow(); + }); + + it('should throw error when schema is missing', () => { + expect(() => { + new RPCApiHandler({} as any); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is not an object', () => { + expect(() => { + new RPCApiHandler({ + schema: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is null', () => { + expect(() => { + new RPCApiHandler({ + schema: null as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is undefined', () => { + expect(() => { + new RPCApiHandler({ + schema: undefined as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log is invalid type', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log array contains invalid values', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: ['debug', 'invalid'] as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log is a number', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: 123 as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log is an object', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: {} as any, + }); + }).toThrow('Invalid options'); + }); + + it('should accept empty log array', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: [], + }); + }).not.toThrow(); + }); + + it('should accept single log level', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: ['error'], + }); + }).not.toThrow(); + }); + + it('should throw error with extra unknown options', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + unknownOption: 'value', + } as any); + }).toThrow('Invalid options'); // z.strictObject() rejects extra properties + }); + }); + + describe('strictObject validation - extra properties', () => { + it('RestApiHandler should reject extra unknown properties', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + extraProperty: 'should-fail', + } as any); + }).toThrow('Invalid options'); + }); + + it('RPCApiHandler should reject extra unknown properties', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + extraProperty: 'should-fail', + } as any); + }).toThrow('Invalid options'); + }); + + it('RestApiHandler should reject multiple extra unknown properties', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + extra1: 'value1', + extra2: 'value2', + } as any); + }).toThrow('Invalid options'); + }); + + it('RPCApiHandler should reject multiple extra unknown properties', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + extra1: 'value1', + extra2: 'value2', + } as any); + }).toThrow('Invalid options'); + }); + }); + + describe('Edge Cases and Type Safety', () => { + it('RestApiHandler should handle undefined optional fields gracefully', () => { + const handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: undefined, + pageSize: undefined, + idDivider: undefined, + }); + expect(handler).toBeDefined(); + }); + + it('RPCApiHandler should handle undefined optional fields gracefully', () => { + const handler = new RPCApiHandler({ + schema: client.$schema, + log: undefined, + }); + expect(handler).toBeDefined(); + }); + + it('RestApiHandler should expose schema property', () => { + const handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + expect(handler.schema).toBe(client.$schema); + }); + + it('RPCApiHandler should expose schema property', () => { + const handler = new RPCApiHandler({ + schema: client.$schema, + }); + expect(handler.schema).toBe(client.$schema); + }); + + it('RestApiHandler should expose log property', () => { + const logConfig = ['debug', 'error'] as const; + const handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: logConfig, + }); + expect(handler.log).toBe(logConfig); + }); + + it('RPCApiHandler should expose log property', () => { + const logConfig = ['debug', 'error'] as const; + const handler = new RPCApiHandler({ + schema: client.$schema, + log: logConfig, + }); + expect(handler.log).toBe(logConfig); + }); + + it('RestApiHandler should handle empty modelNameMapping', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: {}, + }); + }).not.toThrow(); + }); + + it('RestApiHandler should handle empty externalIdMapping', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + externalIdMapping: {}, + }); + }).not.toThrow(); + }); + }); + + describe('Real-world Scenarios', () => { + it('RestApiHandler with production-like configuration', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'https://api.example.com/v1', + log: (level, message) => { + if (level === 'error') { + console.error(message); + } + }, + pageSize: 100, + idDivider: '_', + modelNameMapping: { + User: 'users', + }, + }); + }).not.toThrow(); + }); + + it('RPCApiHandler with production-like configuration', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: (level, message) => { + if (level === 'error') { + console.error(message); + } + }, + }); + }).not.toThrow(); + }); + + it('RestApiHandler with disabled pagination (Infinity pageSize)', () => { + // Note: According to the code, this would need to be set to Infinity + // after construction, not in options, as Zod validation requires positive number + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 999999, // Large number as workaround + }); + }).not.toThrow(); + }); + }); + + describe('Schema validation', () => { + it('RestApiHandler should validate schema structure', () => { + const validSchema = client.$schema; + expect(() => { + new RestApiHandler({ + schema: validSchema, + endpoint: 'http://localhost/api', + }); + }).not.toThrow(); + }); + + it('RPCApiHandler should validate schema structure', () => { + const validSchema = client.$schema; + expect(() => { + new RPCApiHandler({ + schema: validSchema, + }); + }).not.toThrow(); + }); + + it('RestApiHandler should handle empty schema object but will error when building type map', () => { + // Empty schema passes Zod validation (z.object()) but fails when building type map + expect(() => { + new RestApiHandler({ + schema: {} as any, + endpoint: 'http://localhost/api', + }); + }).toThrow(); // Throws when trying to build type map from empty schema + }); + }); +});