fix(engine): validation errors return 400 instead of 500 (closes #34)

- 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
This commit is contained in:
小墨 2026-04-13 09:07:50 +00:00
parent edeb549162
commit d06d000ec0
3 changed files with 75 additions and 62 deletions

View File

@ -2,6 +2,15 @@
// sources[] replaces driven_by + bindings + expression // sources[] replaces driven_by + bindings + expression
import jsonata from 'jsonata' 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 { import type {
ObjectDef, ObjectDef,
Object, Object,
@ -88,7 +97,7 @@ export async function getObjectDef(db: D1Database, name: string): Promise<Object
export async function createObject(db: D1Database, typeName: string): Promise<Object> { export async function createObject(db: D1Database, typeName: string): Promise<Object> {
const exists = await db.prepare('SELECT 1 FROM object_defs WHERE name = ?').bind(typeName).first<{ 1: number }>() 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 createdAt = Date.now()
const result = await db const result = await db
@ -141,15 +150,15 @@ export async function listObjects(
function validateEventSchema(schema: { properties: Record<string, PropertyDef> }): void { function validateEventSchema(schema: { properties: Record<string, PropertyDef> }): void {
if (!schema.properties || typeof schema.properties !== 'object') { 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)) { for (const [key, prop] of Object.entries(schema.properties)) {
if (!['ref', 'string', 'number', 'boolean'].includes(prop.type)) { 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 (prop.type === 'ref' && prop.object_type) {
if (typeof prop.object_type !== 'string' && !Array.isArray(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) { switch (propDef.type) {
case 'string': 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 break
case 'number': 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 break
case 'boolean': 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 break
case 'ref': { 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) 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) { if (propDef.object_type) {
const allowedTypes = Array.isArray(propDef.object_type) ? propDef.object_type : [propDef.object_type] const allowedTypes = Array.isArray(propDef.object_type) ? propDef.object_type : [propDef.object_type]
if (!allowedTypes.includes(obj.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 }) refProperties.set(key, { refId: value, objectType: propDef.object_type })
@ -281,7 +290,7 @@ export async function createEvent(
payload: Record<string, any>, payload: Record<string, any>,
): Promise<{ event: Event; reactions_fired: number; reaction_results: ReactionPayload[] }> { ): Promise<{ event: Event; reactions_fired: number; reaction_results: ReactionPayload[] }> {
const typeHash = await resolveEventDefName(db, typeName) 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 const schemaRow = await db
.prepare('SELECT schema FROM event_def_versions WHERE hash = ?') .prepare('SELECT schema FROM event_def_versions WHERE hash = ?')
@ -428,39 +437,39 @@ export async function findEventsByRef(
function validateValueSchema(valueSchema: { type: string }): void { function validateValueSchema(valueSchema: { type: string }): void {
if (!valueSchema || !valueSchema.type) { 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'] const validTypes = ['ref', 'string', 'number', 'boolean', 'array', 'object']
if (!validTypes.includes(valueSchema.type)) { 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 { function validateInitialValue(initialValue: any, valueSchema: { type: string }): void {
if (initialValue === undefined || initialValue === null) { 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) { switch (valueSchema.type) {
case 'string': 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 break
case 'number': 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 break
case 'boolean': 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 break
case 'ref': 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 break
case 'array': 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 break
case 'object': case 'object':
if (typeof initialValue !== 'object' || Array.isArray(initialValue)) if (typeof initialValue !== 'object' || Array.isArray(initialValue))
throw new Error('initial_value must be object') throw new ValidationError('initial_value must be object')
break break
} }
} }
@ -477,21 +486,21 @@ export async function createProjectionDef(
validateInitialValue(initialValue, valueSchema) validateInitialValue(initialValue, valueSchema)
if (!sources || sources.length === 0) { 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<string, string>; expression: string }> = [] const resolvedSources: Array<{ event_def_hash: string; bindings: Record<string, string>; expression: string }> = []
for (const source of sources) { for (const source of sources) {
const eventHash = await resolveEventDefName(db, source.event_def) 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)) { 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('$')) { if (value.startsWith('$')) {
const paramKey = value.slice(1) const paramKey = value.slice(1)
if (!(paramKey in params)) 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`)
} }
} }

View File

@ -1282,11 +1282,11 @@ describe('Error Cases: Object Defs', () => {
}) })
describe('Error Cases: Objects', () => { 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 }) 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() 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') 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') 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: {} }), { const res = await app.fetch(req('POST', '/event-defs', { name: 'test', schema: {} }), {
DB: db, DB: db,
API_TOKEN: API_TOKEN, API_TOKEN: API_TOKEN,
}) })
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') 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 schema = { properties: { field: { type: 'invalid' } } }
const res = await app.fetch(req('POST', '/event-defs', { name: 'test', schema }), { const res = await app.fetch(req('POST', '/event-defs', { name: 'test', schema }), {
DB: db, DB: db,
API_TOKEN: API_TOKEN, API_TOKEN: API_TOKEN,
}) })
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') 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 }) 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: {} }), { const res = await app.fetch(req('POST', '/events', { type: 'nonexistent', payload: {} }), {
DB: db, DB: db,
API_TOKEN: API_TOKEN, API_TOKEN: API_TOKEN,
}) })
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') 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 } }), { const res = await app.fetch(req('POST', '/events', { type: 'test_event', payload: { participant: 99999 } }), {
DB: db, DB: db,
API_TOKEN: API_TOKEN, API_TOKEN: API_TOKEN,
}) })
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') 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 }) 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 taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const taskId = (await taskRes.json()).id const taskId = (await taskRes.json()).id
@ -1399,20 +1399,20 @@ describe('Error Cases: Events', () => {
DB: db, DB: db,
API_TOKEN: API_TOKEN, API_TOKEN: API_TOKEN,
}) })
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') 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( const res = await app.fetch(
req('POST', '/events', { type: 'test_event', payload: { participant: agentId, count: 'string' } }), req('POST', '/events', { type: 'test_event', payload: { participant: agentId, count: 'string' } }),
{ DB: db, API_TOKEN: API_TOKEN }, { DB: db, API_TOKEN: API_TOKEN },
) )
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') expect(json.error.message).toContain('must be number')
}) })
@ -1550,7 +1550,7 @@ describe('Error Cases: Projection Defs', () => {
expect(json.error.message).toContain('sources') 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( const res = await app.fetch(
req('POST', '/projection-defs', { req('POST', '/projection-defs', {
name: 'test_proj', name: 'test_proj',
@ -1561,13 +1561,13 @@ describe('Error Cases: Projection Defs', () => {
}), }),
{ DB: db, API_TOKEN: API_TOKEN }, { DB: db, API_TOKEN: API_TOKEN },
) )
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') 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( const res = await app.fetch(
req('POST', '/projection-defs', { req('POST', '/projection-defs', {
name: 'test_proj', name: 'test_proj',
@ -1578,13 +1578,13 @@ describe('Error Cases: Projection Defs', () => {
}), }),
{ DB: db, API_TOKEN: API_TOKEN }, { DB: db, API_TOKEN: API_TOKEN },
) )
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') 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( const res = await app.fetch(
req('POST', '/projection-defs', { req('POST', '/projection-defs', {
name: 'test_proj', name: 'test_proj',
@ -1595,9 +1595,9 @@ describe('Error Cases: Projection Defs', () => {
}), }),
{ DB: db, API_TOKEN: API_TOKEN }, { DB: db, API_TOKEN: API_TOKEN },
) )
expect(res.status).toBe(500) expect(res.status).toBe(400)
const json = await res.json() 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') expect(json.error.message).toContain('Event type nonexistent not defined')
}) })

View File

@ -39,6 +39,7 @@ import {
deleteApiKey, deleteApiKey,
validateApiKey, validateApiKey,
getSchema, getSchema,
ValidationError,
} from './engine' } from './engine'
import type { import type {
CreateObjectDefRequest, CreateObjectDefRequest,
@ -158,7 +159,7 @@ app.post('/object-defs', async (c) => {
const result = await createObjectDef(c.env.DB, body.name) const result = await createObjectDef(c.env.DB, body.name)
return c.json(result, 201) return c.json(result, 201)
} catch (err: any) { } 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) const obj = await createObject(c.env.DB, body.type)
return c.json(obj, 201) return c.json(obj, 201)
} catch (err: any) { } 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) const result = await createEventDef(c.env.DB, body.name, body.schema)
return c.json(result, 201) return c.json(result, 201)
} catch (err: any) { } 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) return c.json({ event, reactions_fired, reaction_results }, 201)
} catch (err: any) { } 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) return c.json(result, 201)
} catch (err: any) { } 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) return c.json(response)
} catch (err: any) { } 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) return c.json(reaction, 201)
} catch (err: any) { } 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) const result = await createApiKey(c.env.DB, body.name, body.allowed_events, body.rate_limit)
return c.json(result, 201) return c.json(result, 201)
} catch (err: any) { } 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')
} }
}) })