feat(api): unify response format + GET single + error codes (#13)
This commit is contained in:
parent
e82fe8eaba
commit
eb7edd8141
@ -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
|
||||
// ============================================
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
// ============================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user