From d06d000ec0f76f01ca9122c288880e277c0ac2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Mon, 13 Apr 2026 09:07:50 +0000 Subject: [PATCH] fix(engine): validation errors return 400 instead of 500 (closes #34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ValidationError class for client-side input errors - POST /events: ref format, type mismatch, nonexistent type → 400 - POST /objects: undefined type → 400 - POST /event-defs: schema validation → 400 - POST /projection-defs: value_schema, initial_value, source validation → 400 - All API handlers: catch ValidationError → 400 INVALID_INPUT - Update 10 test cases to expect 400 + INVALID_INPUT --- packages/engine/src/engine.ts | 57 ++++++++++++++++------------- packages/engine/src/index.test.ts | 60 +++++++++++++++---------------- packages/engine/src/index.ts | 20 ++++++----- 3 files changed, 75 insertions(+), 62 deletions(-) diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index 03f6b96..25279b7 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -2,6 +2,15 @@ // sources[] replaces driven_by + bindings + expression import jsonata from 'jsonata' + +/** Thrown for client-side input errors (should map to HTTP 400). */ +export class ValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'ValidationError' + } +} + import type { ObjectDef, Object, @@ -88,7 +97,7 @@ export async function getObjectDef(db: D1Database, name: string): Promise { const exists = await db.prepare('SELECT 1 FROM object_defs WHERE name = ?').bind(typeName).first<{ 1: number }>() - if (!exists) throw new Error(`Object type ${typeName} not defined`) + if (!exists) throw new ValidationError(`Object type ${typeName} not defined`) const createdAt = Date.now() const result = await db @@ -141,15 +150,15 @@ export async function listObjects( function validateEventSchema(schema: { properties: Record }): void { if (!schema.properties || typeof schema.properties !== 'object') { - throw new Error('schema must have properties object') + throw new ValidationError('schema must have properties object') } for (const [key, prop] of Object.entries(schema.properties)) { if (!['ref', 'string', 'number', 'boolean'].includes(prop.type)) { - throw new Error(`Invalid property type for ${key}: ${prop.type}`) + throw new ValidationError(`Invalid property type for ${key}: ${prop.type}`) } if (prop.type === 'ref' && prop.object_type) { if (typeof prop.object_type !== 'string' && !Array.isArray(prop.object_type)) { - throw new Error(`object_type for ${key} must be string or array`) + throw new ValidationError(`object_type for ${key} must be string or array`) } } } @@ -248,22 +257,22 @@ async function validateEventPayload( switch (propDef.type) { case 'string': - if (typeof value !== 'string') throw new Error(`${key} must be string`) + if (typeof value !== 'string') throw new ValidationError(`${key} must be string`) break case 'number': - if (typeof value !== 'number') throw new Error(`${key} must be number`) + if (typeof value !== 'number') throw new ValidationError(`${key} must be number`) break case 'boolean': - if (typeof value !== 'boolean') throw new Error(`${key} must be boolean`) + if (typeof value !== 'boolean') throw new ValidationError(`${key} must be boolean`) break case 'ref': { - if (typeof value !== 'number') throw new Error(`${key} must be ref (number)`) + if (typeof value !== 'number') throw new ValidationError(`${key} must be ref (number)`) const obj = await getObject(db, value) - if (!obj) throw new Error(`Referenced object ${value} does not exist`) + if (!obj) throw new ValidationError(`Referenced object ${value} does not exist`) if (propDef.object_type) { const allowedTypes = Array.isArray(propDef.object_type) ? propDef.object_type : [propDef.object_type] if (!allowedTypes.includes(obj.type)) { - throw new Error(`${key} ref ${value} type mismatch: expected ${allowedTypes.join('|')}, got ${obj.type}`) + throw new ValidationError(`${key} ref ${value} type mismatch: expected ${allowedTypes.join('|')}, got ${obj.type}`) } } refProperties.set(key, { refId: value, objectType: propDef.object_type }) @@ -281,7 +290,7 @@ export async function createEvent( payload: Record, ): Promise<{ event: Event; reactions_fired: number; reaction_results: ReactionPayload[] }> { const typeHash = await resolveEventDefName(db, typeName) - if (!typeHash) throw new Error(`Event type ${typeName} not defined`) + if (!typeHash) throw new ValidationError(`Event type ${typeName} not defined`) const schemaRow = await db .prepare('SELECT schema FROM event_def_versions WHERE hash = ?') @@ -428,39 +437,39 @@ export async function findEventsByRef( function validateValueSchema(valueSchema: { type: string }): void { if (!valueSchema || !valueSchema.type) { - throw new Error('value_schema must have type field') + throw new ValidationError('value_schema must have type field') } const validTypes = ['ref', 'string', 'number', 'boolean', 'array', 'object'] if (!validTypes.includes(valueSchema.type)) { - throw new Error(`Invalid value_schema type: ${valueSchema.type}`) + throw new ValidationError(`Invalid value_schema type: ${valueSchema.type}`) } } function validateInitialValue(initialValue: any, valueSchema: { type: string }): void { if (initialValue === undefined || initialValue === null) { - throw new Error('initial_value is required (cannot be null or undefined)') + throw new ValidationError('initial_value is required (cannot be null or undefined)') } // 简单类型校验 switch (valueSchema.type) { case 'string': - if (typeof initialValue !== 'string') throw new Error('initial_value must be string') + if (typeof initialValue !== 'string') throw new ValidationError('initial_value must be string') break case 'number': - if (typeof initialValue !== 'number') throw new Error('initial_value must be number') + if (typeof initialValue !== 'number') throw new ValidationError('initial_value must be number') break case 'boolean': - if (typeof initialValue !== 'boolean') throw new Error('initial_value must be boolean') + if (typeof initialValue !== 'boolean') throw new ValidationError('initial_value must be boolean') break case 'ref': - if (typeof initialValue !== 'string') throw new Error('initial_value for ref must be string') + if (typeof initialValue !== 'string') throw new ValidationError('initial_value for ref must be string') break case 'array': - if (!Array.isArray(initialValue)) throw new Error('initial_value must be array') + if (!Array.isArray(initialValue)) throw new ValidationError('initial_value must be array') break case 'object': if (typeof initialValue !== 'object' || Array.isArray(initialValue)) - throw new Error('initial_value must be object') + throw new ValidationError('initial_value must be object') break } } @@ -477,21 +486,21 @@ export async function createProjectionDef( validateInitialValue(initialValue, valueSchema) if (!sources || sources.length === 0) { - throw new Error('At least one source is required') + throw new ValidationError('At least one source is required') } const resolvedSources: Array<{ event_def_hash: string; bindings: Record; expression: string }> = [] for (const source of sources) { const eventHash = await resolveEventDefName(db, source.event_def) - if (!eventHash) throw new Error(`Event type ${source.event_def} not defined`) + if (!eventHash) throw new ValidationError(`Event type ${source.event_def} not defined`) for (const [, value] of Object.entries(source.bindings)) { - if (typeof value !== 'string') throw new Error('All binding values must be strings') + if (typeof value !== 'string') throw new ValidationError('All binding values must be strings') if (value.startsWith('$')) { const paramKey = value.slice(1) if (!(paramKey in params)) - throw new Error(`Binding references param ${paramKey} which is not defined in params`) + throw new ValidationError(`Binding references param ${paramKey} which is not defined in params`) } } diff --git a/packages/engine/src/index.test.ts b/packages/engine/src/index.test.ts index d7e85a5..0ea684a 100644 --- a/packages/engine/src/index.test.ts +++ b/packages/engine/src/index.test.ts @@ -1282,11 +1282,11 @@ describe('Error Cases: Object Defs', () => { }) describe('Error Cases: Objects', () => { - it('POST /objects type not defined → 500', async () => { + it('POST /objects type not defined → 400', async () => { const res = await app.fetch(req('POST', '/objects', { type: 'nonexistent' }), { DB: db, API_TOKEN: API_TOKEN }) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('Object type nonexistent not defined') }) @@ -1314,26 +1314,26 @@ describe('Error Cases: Event Defs', () => { expect(json.error.code).toBe('MISSING_FIELD') }) - it('POST /event-defs schema without properties → 500', async () => { + it('POST /event-defs schema without properties → 400', async () => { const res = await app.fetch(req('POST', '/event-defs', { name: 'test', schema: {} }), { DB: db, API_TOKEN: API_TOKEN, }) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('properties') }) - it('POST /event-defs invalid property type → 500', async () => { + it('POST /event-defs invalid property type → 400', async () => { const schema = { properties: { field: { type: 'invalid' } } } const res = await app.fetch(req('POST', '/event-defs', { name: 'test', schema }), { DB: db, API_TOKEN: API_TOKEN, }) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('Invalid property type') }) @@ -1369,29 +1369,29 @@ describe('Error Cases: Events', () => { await app.fetch(req('POST', '/event-defs', { name: 'test_event', schema }), { DB: db, API_TOKEN: API_TOKEN }) }) - it('POST /events type not defined → 500', async () => { + it('POST /events type not defined → 400', async () => { const res = await app.fetch(req('POST', '/events', { type: 'nonexistent', payload: {} }), { DB: db, API_TOKEN: API_TOKEN, }) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('Event type nonexistent not defined') }) - it('POST /events ref nonexistent object → 500', async () => { + it('POST /events ref nonexistent object → 400', async () => { const res = await app.fetch(req('POST', '/events', { type: 'test_event', payload: { participant: 99999 } }), { DB: db, API_TOKEN: API_TOKEN, }) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('does not exist') }) - it('POST /events ref type mismatch → 500', async () => { + it('POST /events ref type mismatch → 400', async () => { await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN }) const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN }) const taskId = (await taskRes.json()).id @@ -1399,20 +1399,20 @@ describe('Error Cases: Events', () => { DB: db, API_TOKEN: API_TOKEN, }) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('type mismatch') }) - it('POST /events payload property wrong type → 500', async () => { + it('POST /events payload property wrong type → 400', async () => { const res = await app.fetch( req('POST', '/events', { type: 'test_event', payload: { participant: agentId, count: 'string' } }), { DB: db, API_TOKEN: API_TOKEN }, ) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('must be number') }) @@ -1550,7 +1550,7 @@ describe('Error Cases: Projection Defs', () => { expect(json.error.message).toContain('sources') }) - it('POST /projection-defs invalid value_schema type → 500', async () => { + it('POST /projection-defs invalid value_schema type → 400', async () => { const res = await app.fetch( req('POST', '/projection-defs', { name: 'test_proj', @@ -1561,13 +1561,13 @@ describe('Error Cases: Projection Defs', () => { }), { DB: db, API_TOKEN: API_TOKEN }, ) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('Invalid value_schema type') }) - it('POST /projection-defs initial_value type mismatch → 500', async () => { + it('POST /projection-defs initial_value type mismatch → 400', async () => { const res = await app.fetch( req('POST', '/projection-defs', { name: 'test_proj', @@ -1578,13 +1578,13 @@ describe('Error Cases: Projection Defs', () => { }), { DB: db, API_TOKEN: API_TOKEN }, ) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('initial_value must be number') }) - it('POST /projection-defs source with nonexistent event → 500', async () => { + it('POST /projection-defs source with nonexistent event → 400', async () => { const res = await app.fetch( req('POST', '/projection-defs', { name: 'test_proj', @@ -1595,9 +1595,9 @@ describe('Error Cases: Projection Defs', () => { }), { DB: db, API_TOKEN: API_TOKEN }, ) - expect(res.status).toBe(500) + expect(res.status).toBe(400) const json = await res.json() - expect(json.error.code).toBe('INTERNAL_ERROR') + expect(json.error.code).toBe('INVALID_INPUT') expect(json.error.message).toContain('Event type nonexistent not defined') }) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 70b4057..18895f0 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -39,6 +39,7 @@ import { deleteApiKey, validateApiKey, getSchema, + ValidationError, } from './engine' import type { CreateObjectDefRequest, @@ -158,7 +159,7 @@ app.post('/object-defs', async (c) => { const result = await createObjectDef(c.env.DB, body.name) return c.json(result, 201) } catch (err: any) { - return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') + if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) @@ -185,7 +186,7 @@ app.post('/objects', async (c) => { const obj = await createObject(c.env.DB, body.type) return c.json(obj, 201) } catch (err: any) { - return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') + if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) @@ -220,7 +221,7 @@ app.post('/event-defs', async (c) => { const result = await createEventDef(c.env.DB, body.name, body.schema) return c.json(result, 201) } catch (err: any) { - return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') + if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) @@ -288,7 +289,10 @@ app.post('/events', async (c) => { return c.json({ event, reactions_fired, reaction_results }, 201) } catch (err: any) { - return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') + if (err?.name === 'ValidationError') { + return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message) + } + if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) @@ -338,7 +342,7 @@ app.post('/projection-defs', async (c) => { ) return c.json(result, 201) } catch (err: any) { - return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') + if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) @@ -374,7 +378,7 @@ app.get('/projections/:name', async (c) => { } return c.json(response) } catch (err: any) { - return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') + if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) @@ -408,7 +412,7 @@ app.post('/reactions', async (c) => { }) return c.json(reaction, 201) } catch (err: any) { - return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') + if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } }) @@ -499,7 +503,7 @@ app.post('/api-keys', async (c) => { const result = await createApiKey(c.env.DB, body.name, body.allowed_events, body.rate_limit) return c.json(result, 201) } catch (err: any) { - return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') + if (err?.name === 'ValidationError') return apiError(c, 400, ErrorCode.INVALID_INPUT, err.message); return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error') } })