feat(api): unify response format + GET single + error codes (#13)

This commit is contained in:
小橘 2026-04-13 02:04:09 +00:00
parent e82fe8eaba
commit eb7edd8141
4 changed files with 368 additions and 53 deletions

View File

@ -77,6 +77,11 @@ export async function listObjectDefs(db: D1Database): Promise<ObjectDef[]> {
return (result.results || []).map((row) => ({ name: row.name }))
}
export async function getObjectDef(db: D1Database, name: string): Promise<ObjectDef | null> {
const row = await db.prepare('SELECT name FROM object_defs WHERE name = ?').bind(name).first<{ name: string }>()
return row ? { name: row.name } : null
}
// ============================================
// Objects
// ============================================
@ -207,6 +212,25 @@ export async function listEventDefs(db: D1Database): Promise<EventDef[]> {
}))
}
export async function getEventDef(db: D1Database, name: string): Promise<EventDef | null> {
const row = await db
.prepare(
`SELECT n.name, n.current_hash, v.parent_hash, v.schema
FROM event_def_names n
JOIN event_def_versions v ON n.current_hash = v.hash
WHERE n.name = ?`,
)
.bind(name)
.first<{ name: string; current_hash: string; parent_hash: string | null; schema: string }>()
if (!row) return null
return {
name: row.name,
hash: row.current_hash,
parent_hash: row.parent_hash,
schema: JSON.parse(row.schema),
}
}
// ============================================
// Events
// ============================================
@ -514,6 +538,48 @@ export async function listProjectionDefs(db: D1Database): Promise<ProjectionDef[
return defs
}
export async function getProjectionDef(db: D1Database, name: string): Promise<ProjectionDef | null> {
const row = await db
.prepare(
`SELECT n.name, n.current_hash, v.parent_hash, v.params, v.value_schema, v.initial_value
FROM projection_def_names n
JOIN projection_def_versions v ON n.current_hash = v.hash
WHERE n.name = ?`,
)
.bind(name)
.first<{
name: string
current_hash: string
parent_hash: string | null
params: string
value_schema: string
initial_value: string
}>()
if (!row) return null
const sourceRows = await db
.prepare('SELECT event_def_hash, bindings, expression FROM projection_def_sources WHERE projection_hash = ?')
.bind(row.current_hash)
.all<{ event_def_hash: string; bindings: string; expression: string }>()
const sources: ProjectionDefSource[] = (sourceRows.results || []).map((s) => ({
event_def_hash: s.event_def_hash,
bindings: JSON.parse(s.bindings),
expression: s.expression,
}))
return {
name: row.name,
hash: row.current_hash,
parent_hash: row.parent_hash,
params: JSON.parse(row.params),
sources,
value_schema: JSON.parse(row.value_schema),
initial_value: JSON.parse(row.initial_value),
}
}
// ============================================
// Projections
// ============================================
@ -796,6 +862,40 @@ export async function deleteReaction(db: D1Database, id: number): Promise<void>
await db.prepare('DELETE FROM reactions WHERE id = ?').bind(id).run()
}
export async function getReaction(db: D1Database, id: number): Promise<Reaction | null> {
const row = await db
.prepare(
'SELECT id, projection_def_hash, params_hash, params, action, webhook_url, emit_event_type, emit_payload_template, handler_code, handler_timeout_ms, created_at FROM reactions WHERE id = ?',
)
.bind(id)
.first<{
id: number
projection_def_hash: string
params_hash: string
params: string
action: string
webhook_url: string | null
emit_event_type: string | null
emit_payload_template: string | null
handler_code: string | null
handler_timeout_ms: number | null
created_at: number
}>()
if (!row) return null
return {
...row,
params: JSON.parse(row.params),
action: (row.action || 'webhook') as 'webhook' | 'emit_event' | 'handler',
webhook_url: row.webhook_url || undefined,
emit_event_type: row.emit_event_type || undefined,
emit_payload_template: row.emit_payload_template || undefined,
handler_code: row.handler_code || undefined,
handler_timeout_ms: row.handler_timeout_ms || undefined,
}
}
// ============================================
// Handler Execution
// ============================================

View File

@ -473,6 +473,7 @@ describe('Object Defs', () => {
expect(res.status).toBe(200)
const json = await res.json()
expect(json.object_defs).toHaveLength(2)
expect(json.total).toBe(2)
expect(json.object_defs[0]).toEqual({ name: 'agent' })
})
})
@ -582,6 +583,7 @@ describe('Event Defs', () => {
expect(res.status).toBe(200)
const json = await res.json()
expect(json.event_defs).toHaveLength(1)
expect(json.total).toBe(1)
expect(json.event_defs[0].name).toBe('task_assigned')
expect(json.event_defs[0].hash).toBeDefined()
expect(json.event_defs[0].parent_hash).toBeNull()
@ -752,6 +754,7 @@ describe('Projection Defs', () => {
expect(res.status).toBe(200)
const json = await res.json()
expect(json.projection_defs).toHaveLength(1)
expect(json.total).toBe(1)
expect(json.projection_defs[0].name).toBe('current_assignee')
expect(json.projection_defs[0].value_schema).toEqual({ type: 'ref' })
expect(json.projection_defs[0].initial_value).toBe('')
@ -1041,19 +1044,153 @@ describe('E2E: Reaction Chain', () => {
})
})
// ============================================
// GET Single Routes
// ============================================
describe('GET /object-defs/:name', () => {
it('returns object def by name', async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/object-defs/agent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.name).toBe('agent')
})
it('returns 404 for nonexistent name', async () => {
const res = await app.fetch(req('GET', '/object-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
})
describe('GET /event-defs/:name', () => {
it('returns event def with schema by name', async () => {
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/event-defs/task_assigned'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.name).toBe('task_assigned')
expect(json.hash).toBeDefined()
expect(json.schema).toEqual(schema)
})
it('returns 404 for nonexistent name', async () => {
const res = await app.fetch(req('GET', '/event-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
})
describe('GET /projection-defs/:name', () => {
beforeEach(async () => {
const schema = { properties: { participant: { type: 'ref' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
})
it('returns projection def with sources/params/value_schema by name', async () => {
const body = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', body), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/projection-defs/current_assignee'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.name).toBe('current_assignee')
expect(json.hash).toBeDefined()
expect(json.value_schema).toEqual({ type: 'ref' })
expect(json.initial_value).toBe('')
expect(json.sources).toHaveLength(1)
expect(json.params).toEqual({ task_id: { type: 'ref' } })
})
it('returns 404 for nonexistent name', async () => {
const res = await app.fetch(req('GET', '/projection-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
})
describe('GET /reactions/:id', () => {
beforeEach(async () => {
const schema = { properties: { participant: { type: 'ref' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const projDef = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
})
it('returns reaction by id', async () => {
const created = await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: 1 },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const { id } = await created.json()
const res = await app.fetch(req('GET', `/reactions/${id}`), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.id).toBe(id)
expect(json.projection_def_hash).toBeDefined()
expect(json.params).toEqual({ task_id: 1 })
expect(json.action).toBe('webhook')
})
it('returns 404 for nonexistent id', async () => {
const res = await app.fetch(req('GET', '/reactions/99999'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
})
describe('Error Cases: Object Defs', () => {
it('POST /object-defs missing name → 400', async () => {
const res = await app.fetch(req('POST', '/object-defs', {}), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error).toContain('Missing name')
expect(json.error.code).toBe('MISSING_FIELD')
expect(json.error.message).toContain('Missing name')
})
it('POST /object-defs empty name → 400', async () => {
const res = await app.fetch(req('POST', '/object-defs', { name: '' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error).toContain('Missing name')
expect(json.error.code).toBe('MISSING_FIELD')
expect(json.error.message).toContain('Missing name')
})
})
@ -1062,12 +1199,15 @@ describe('Error Cases: Objects', () => {
const res = await app.fetch(req('POST', '/objects', { type: 'nonexistent' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('Object type nonexistent not defined')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Object type nonexistent not defined')
})
it('GET /objects/:id not found → 404', async () => {
const res = await app.fetch(req('GET', '/objects/99999'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
it('GET /objects?type=nonexistent → returns empty array', async () => {
@ -1083,6 +1223,8 @@ describe('Error Cases: Event Defs', () => {
it('POST /event-defs missing schema → 400', async () => {
const res = await app.fetch(req('POST', '/event-defs', { name: 'test' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
})
it('POST /event-defs schema without properties → 500', async () => {
@ -1092,7 +1234,8 @@ describe('Error Cases: Event Defs', () => {
})
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('properties')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('properties')
})
it('POST /event-defs invalid property type → 500', async () => {
@ -1103,7 +1246,8 @@ describe('Error Cases: Event Defs', () => {
})
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('Invalid property type')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Invalid property type')
})
it('POST /event-defs duplicate schema → idempotent (same hash)', async () => {
@ -1145,7 +1289,8 @@ describe('Error Cases: Events', () => {
})
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('Event type nonexistent not defined')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Event type nonexistent not defined')
})
it('POST /events ref nonexistent object → 500', async () => {
@ -1155,7 +1300,8 @@ describe('Error Cases: Events', () => {
})
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('does not exist')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('does not exist')
})
it('POST /events ref type mismatch → 500', async () => {
@ -1168,7 +1314,8 @@ describe('Error Cases: Events', () => {
})
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('type mismatch')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('type mismatch')
})
it('POST /events payload property wrong type → 500', async () => {
@ -1178,12 +1325,15 @@ describe('Error Cases: Events', () => {
)
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('must be number')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('must be number')
})
it('GET /events/:id not found → 404', async () => {
const res = await app.fetch(req('GET', '/events/99999'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
it('GET /events without ref → returns all events', async () => {
@ -1220,6 +1370,8 @@ describe('Error Cases: Projection Defs', () => {
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
})
it('POST /projection-defs missing initial_value → 400', async () => {
@ -1233,6 +1385,8 @@ describe('Error Cases: Projection Defs', () => {
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
})
it('POST /projection-defs missing sources → 400', async () => {
@ -1247,7 +1401,8 @@ describe('Error Cases: Projection Defs', () => {
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error).toContain('sources')
expect(json.error.code).toBe('MISSING_FIELD')
expect(json.error.message).toContain('sources')
})
it('POST /projection-defs invalid value_schema type → 500', async () => {
@ -1263,7 +1418,8 @@ describe('Error Cases: Projection Defs', () => {
)
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('Invalid value_schema type')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Invalid value_schema type')
})
it('POST /projection-defs initial_value type mismatch → 500', async () => {
@ -1279,7 +1435,8 @@ describe('Error Cases: Projection Defs', () => {
)
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('initial_value must be number')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('initial_value must be number')
})
it('POST /projection-defs source with nonexistent event → 500', async () => {
@ -1295,7 +1452,8 @@ describe('Error Cases: Projection Defs', () => {
)
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('Event type nonexistent not defined')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Event type nonexistent not defined')
})
it('POST /projection-defs duplicate definition → idempotent (same hash)', async () => {
@ -1319,7 +1477,8 @@ describe('Error Cases: Projections', () => {
const res = await app.fetch(req('GET', '/projections/nonexistent?param=value'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('Projection def nonexistent not found')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Projection def nonexistent not found')
})
})
@ -1335,7 +1494,8 @@ describe('Error Cases: Reactions', () => {
)
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error).toContain('Projection def nonexistent not found')
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Projection def nonexistent not found')
})
it('DELETE /reactions/:id nonexistent → 200 (idempotent)', async () => {
@ -1530,7 +1690,8 @@ describe('Reaction Handler', () => {
const res = await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error).toContain('handler_code is required')
expect(json.error.code).toBe('MISSING_FIELD')
expect(json.error.message).toContain('handler_code is required')
})
it('handler receives context (old_value, new_value, params, event)', async () => {
@ -1975,8 +2136,8 @@ describe('Request Logs', () => {
const logsRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN })
const logsJson = await logsRes.json()
expect(logsJson.logs.length).toBeGreaterThanOrEqual(1)
const postLog = logsJson.logs.find((l: any) => l.path === '/events' && l.method === 'POST')
expect(logsJson.request_logs.length).toBeGreaterThanOrEqual(1)
const postLog = logsJson.request_logs.find((l: any) => l.path === '/events' && l.method === 'POST')
expect(postLog).toBeDefined()
expect(postLog.status_code).toBe(201)
})
@ -1999,7 +2160,7 @@ describe('Request Logs', () => {
const logsRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN })
const logsJson = await logsRes.json()
const postLog = logsJson.logs.find((l: any) => l.path === '/events' && l.method === 'POST')
const postLog = logsJson.request_logs.find((l: any) => l.path === '/events' && l.method === 'POST')
expect(postLog).toBeDefined()
expect(postLog.api_key_name).toBe('log-test-key')
expect(postLog.api_key_id).toBeDefined()
@ -2030,11 +2191,11 @@ describe('Request Logs', () => {
const filteredRes = await app.fetch(req('GET', `/request-logs?api_key_id=${keyId}`), { DB: db, API_TOKEN })
const filteredJson = await filteredRes.json()
expect(filteredJson.logs.length).toBe(1)
expect(filteredJson.logs[0].api_key_id).toBe(keyId)
expect(filteredJson.request_logs.length).toBe(1)
expect(filteredJson.request_logs[0].api_key_id).toBe(keyId)
const allRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN })
const allJson = await allRes.json()
expect(allJson.logs.length).toBe(2)
expect(allJson.request_logs.length).toBe(2)
})
})

View File

@ -15,19 +15,23 @@ import UI_HTML from './ui.html'
import {
createObjectDef,
listObjectDefs,
getObjectDef,
createObject,
getObject,
listObjects,
createEventDef,
listEventDefs,
getEventDef,
createEvent,
getEvent,
findEventsByRef,
createProjectionDef,
listProjectionDefs,
getProjectionDef,
getProjection,
createReaction,
listReactions,
getReaction,
deleteReaction,
listReactionLogs,
createApiKey,
@ -45,6 +49,7 @@ import type {
CreateApiKeyRequest,
ReactionPayload,
} from './types'
import { ErrorCode } from './types'
type Bindings = {
DB: D1Database
@ -58,6 +63,10 @@ type Variables = {
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
function apiError(c: any, status: number, code: string, message: string) {
return c.json({ error: { code, message } }, status)
}
app.use('*', cors())
app.use('*', async (c, next) => {
@ -128,17 +137,24 @@ app.use('*', async (c, next) => {
app.post('/object-defs', async (c) => {
try {
const body = await c.req.json<CreateObjectDefRequest>()
if (!body.name) return c.json({ error: 'Missing name' }, 400)
if (!body.name) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name')
const result = await createObjectDef(c.env.DB, body.name)
return c.json(result, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/object-defs/:name', async (c) => {
const name = c.req.param('name')
const def = await getObjectDef(c.env.DB, name)
if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Object def '${name}' not found`)
return c.json(def)
})
app.get('/object-defs', async (c) => {
const defs = await listObjectDefs(c.env.DB)
return c.json({ object_defs: defs })
return c.json({ object_defs: defs, total: defs.length })
})
// ============================================
@ -148,19 +164,19 @@ app.get('/object-defs', async (c) => {
app.post('/objects', async (c) => {
try {
const body = await c.req.json<CreateObjectRequest>()
if (!body.type) return c.json({ error: 'Missing type' }, 400)
if (!body.type) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing type')
const obj = await createObject(c.env.DB, body.type)
return c.json(obj, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/objects/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
if (isNaN(id)) return c.json({ error: 'Invalid id' }, 400)
if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id')
const obj = await getObject(c.env.DB, id)
if (!obj) return c.json({ error: 'Not found' }, 404)
if (!obj) return apiError(c, 404, ErrorCode.NOT_FOUND, 'Not found')
return c.json(obj)
})
@ -183,17 +199,24 @@ app.get('/objects', async (c) => {
app.post('/event-defs', async (c) => {
try {
const body = await c.req.json<CreateEventDefRequest>()
if (!body.name || !body.schema) return c.json({ error: 'Missing name or schema' }, 400)
if (!body.name || !body.schema) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name or schema')
const result = await createEventDef(c.env.DB, body.name, body.schema)
return c.json(result, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/event-defs/:name', async (c) => {
const name = c.req.param('name')
const def = await getEventDef(c.env.DB, name)
if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Event def '${name}' not found`)
return c.json(def)
})
app.get('/event-defs', async (c) => {
const defs = await listEventDefs(c.env.DB)
return c.json({ event_defs: defs })
return c.json({ event_defs: defs, total: defs.length })
})
// ============================================
@ -207,26 +230,26 @@ app.post('/events', async (c) => {
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!bearerToken) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401)
return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Missing or invalid Authorization header')
}
let body: CreateEventRequest
try {
body = await c.req.json<CreateEventRequest>()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
return apiError(c, 400, ErrorCode.INVALID_JSON, 'Invalid JSON body')
}
if (!body.type || !body.payload) return c.json({ error: 'Missing type or payload' }, 400)
if (!body.type || !body.payload) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing type or payload')
// If the token is not API_TOKEN, treat it as an API key
if (bearerToken !== c.env.API_TOKEN) {
const result = await validateApiKey(c.env.DB, bearerToken, body.type)
if (!result.valid) {
if (result.error === 'event_not_allowed') {
return c.json({ error: 'Event type not allowed for this API key' }, 403)
return apiError(c, 403, ErrorCode.FORBIDDEN, 'Event type not allowed for this API key')
}
return c.json({ error: 'Invalid API key' }, 401)
return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Invalid API key')
}
if (result.apiKey) {
c.set('apiKeyId', result.apiKey.id)
@ -248,15 +271,15 @@ app.post('/events', async (c) => {
return c.json({ event, reactions_fired, reaction_results }, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/events/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
if (isNaN(id)) return c.json({ error: 'Invalid id' }, 400)
if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id')
const event = await getEvent(c.env.DB, id)
if (!event) return c.json({ error: 'Not found' }, 404)
if (!event) return apiError(c, 404, ErrorCode.NOT_FOUND, 'Not found')
return c.json(event)
})
@ -281,10 +304,10 @@ app.post('/projection-defs', async (c) => {
try {
const body = await c.req.json<CreateProjectionDefRequest>()
if (!body.name || !body.value_schema || body.initial_value === undefined) {
return c.json({ error: 'Missing name, value_schema, or initial_value' }, 400)
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name, value_schema, or initial_value')
}
if (!body.sources || !Array.isArray(body.sources) || body.sources.length === 0) {
return c.json({ error: 'Missing or empty sources array' }, 400)
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing or empty sources array')
}
const result = await createProjectionDef(
c.env.DB,
@ -296,13 +319,20 @@ app.post('/projection-defs', async (c) => {
)
return c.json(result, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/projection-defs/:name', async (c) => {
const name = c.req.param('name')
const def = await getProjectionDef(c.env.DB, name)
if (!def) return apiError(c, 404, ErrorCode.NOT_FOUND, `Projection def '${name}' not found`)
return c.json(def)
})
app.get('/projection-defs', async (c) => {
const defs = await listProjectionDefs(c.env.DB)
return c.json({ projection_defs: defs })
return c.json({ projection_defs: defs, total: defs.length })
})
// ============================================
@ -320,7 +350,7 @@ app.get('/projections/:name', async (c) => {
const value = await getProjection(c.env.DB, name, params)
return c.json({ value })
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
@ -333,16 +363,16 @@ app.post('/reactions', async (c) => {
const body = await c.req.json<CreateReactionRequest>()
const action = body.action || 'webhook'
if (!body.projection_def || !body.params) {
return c.json({ error: 'Missing projection_def or params' }, 400)
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing projection_def or params')
}
if (action === 'webhook' && !body.webhook_url) {
return c.json({ error: 'webhook_url is required when action is webhook' }, 400)
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'webhook_url is required when action is webhook')
}
if (action === 'emit_event' && !body.emit_event_type) {
return c.json({ error: 'emit_event_type is required when action is emit_event' }, 400)
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'emit_event_type is required when action is emit_event')
}
if (action === 'handler' && !body.handler_code) {
return c.json({ error: 'handler_code is required when action is handler' }, 400)
return apiError(c, 400, ErrorCode.MISSING_FIELD, 'handler_code is required when action is handler')
}
const reaction = await createReaction(c.env.DB, body.projection_def, body.params, {
action,
@ -354,10 +384,18 @@ app.post('/reactions', async (c) => {
})
return c.json(reaction, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})
app.get('/reactions/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10)
if (isNaN(id)) return apiError(c, 400, ErrorCode.INVALID_INPUT, 'Invalid id')
const reaction = await getReaction(c.env.DB, id)
if (!reaction) return apiError(c, 404, ErrorCode.NOT_FOUND, `Reaction ${id} not found`)
return c.json(reaction)
})
app.get('/reactions', async (c) => {
const limitParam = c.req.query('limit')
const offsetParam = c.req.query('offset')
@ -422,7 +460,7 @@ app.get('/request-logs', async (c) => {
.first<{ total: number }>(),
])
return c.json({ logs: rows.results, total: countRow?.total || 0 })
return c.json({ request_logs: rows.results, total: countRow?.total || 0 })
})
// ============================================
@ -432,11 +470,11 @@ app.get('/request-logs', async (c) => {
app.post('/api-keys', async (c) => {
try {
const body = await c.req.json<CreateApiKeyRequest>()
if (!body.name) return c.json({ error: 'Missing name' }, 400)
if (!body.name) return apiError(c, 400, ErrorCode.MISSING_FIELD, 'Missing name')
const result = await createApiKey(c.env.DB, body.name, body.role, body.allowed_events, body.rate_limit)
return c.json(result, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Internal error' }, 500)
return apiError(c, 500, ErrorCode.INTERNAL_ERROR, err.message || 'Internal error')
}
})

View File

@ -4,6 +4,22 @@
// projection_def 加 value_schema NOT NULL + initial_value NOT NULL
// objects.type 直接存名字(不存 hash)
// ============================================
// Error Codes
// ============================================
export const ErrorCode = {
MISSING_FIELD: 'MISSING_FIELD',
INVALID_INPUT: 'INVALID_INPUT',
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
INTERNAL_ERROR: 'INTERNAL_ERROR',
INVALID_JSON: 'INVALID_JSON',
} as const
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
// ============================================
// Definition Layer
// ============================================