feat: unified invoke endpoint — sigil.* builtin capabilities (refs #1)
- Add POST /_api/invoke unified entry point for all capabilities - Support builtin sigil.* capabilities: discover, deploy, remove, inspect, list, status - External capabilities routed through backend.invoke() as before - Comprehensive test coverage in s14-unified-invoke.test.ts - All existing routes preserved for backward compatibility - Error handling for invalid JSON and missing capability field
This commit is contained in:
+142
@@ -43,6 +43,11 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
|
||||
return handleInspect(capability, request, env)
|
||||
}
|
||||
|
||||
// POST /_api/invoke — unified entry point
|
||||
if (method === 'POST' && path === '/_api/invoke') {
|
||||
return handleUnifiedInvoke(request, env)
|
||||
}
|
||||
|
||||
// /run/{capability} — invoke
|
||||
const runMatch = path.match(/^\/run\/([^/]+)$/)
|
||||
if (runMatch) {
|
||||
@@ -191,6 +196,143 @@ async function handleInspect(capability: string, request: Request, env: RouterEn
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnifiedInvoke(request: Request, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
await env.auth.validateToken(authHeader)
|
||||
|
||||
let body: { capability: string, params?: any }
|
||||
try {
|
||||
body = await request.json() as { capability: string, params?: any }
|
||||
} catch (e) {
|
||||
return jsonError(400, 'Invalid JSON in request body')
|
||||
}
|
||||
|
||||
const { capability, params = {} } = body
|
||||
|
||||
if (!capability) {
|
||||
return jsonError(400, 'capability field is required')
|
||||
}
|
||||
|
||||
// Handle builtin capabilities
|
||||
if (capability.startsWith('sigil.')) {
|
||||
return handleBuiltinCapability(capability, params, request, env)
|
||||
}
|
||||
|
||||
// Handle external capabilities
|
||||
const wrappedRequest = new Request(request.url, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
return await env.backend.invoke(capability, wrappedRequest)
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
return jsonError(e.status, e.message)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuiltinCapability(
|
||||
capability: string,
|
||||
params: any,
|
||||
request: Request,
|
||||
env: RouterEnv
|
||||
): Promise<Response> {
|
||||
switch (capability) {
|
||||
case 'sigil.discover':
|
||||
return handleBuiltinQuery(params, env)
|
||||
case 'sigil.deploy':
|
||||
return handleBuiltinDeploy(params, env)
|
||||
case 'sigil.remove':
|
||||
return handleBuiltinRemove(params, env)
|
||||
case 'sigil.inspect':
|
||||
return handleBuiltinInspect(params, request, env)
|
||||
case 'sigil.list':
|
||||
return handleBuiltinList(params, env)
|
||||
case 'sigil.status':
|
||||
return handleBuiltinStatus(env)
|
||||
default:
|
||||
return jsonError(404, `Builtin capability not found: ${capability}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuiltinQuery(params: any, env: RouterEnv): Promise<Response> {
|
||||
const result = await env.backend.query({
|
||||
q: params.q,
|
||||
mode: params.mode,
|
||||
limit: params.limit,
|
||||
cursor: params.cursor
|
||||
})
|
||||
return jsonOk(result)
|
||||
}
|
||||
|
||||
async function handleBuiltinDeploy(params: any, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
// Check deploy cooldown
|
||||
await env.auth.checkDeployCooldown()
|
||||
|
||||
const result = await env.backend.deploy({
|
||||
name: params.name,
|
||||
code: params.code,
|
||||
execute: params.execute,
|
||||
schema: params.schema,
|
||||
type: params.type,
|
||||
ttl: params.ttl,
|
||||
bindings: params.bindings,
|
||||
description: params.description,
|
||||
tags: params.tags,
|
||||
examples: params.examples,
|
||||
requires: params.requires,
|
||||
})
|
||||
|
||||
// Set cooldown after successful deploy
|
||||
await env.auth.setDeployCooldown()
|
||||
|
||||
return jsonOk(result, 201)
|
||||
} catch (e) {
|
||||
if (e instanceof DeployCooldownError) {
|
||||
return jsonError(429, 'Deploy cooldown active', { retry_after: e.retry_after })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuiltinRemove(params: any, env: RouterEnv): Promise<Response> {
|
||||
if (!params.capability) {
|
||||
return jsonError(400, 'capability field is required')
|
||||
}
|
||||
|
||||
await env.backend.remove(params.capability)
|
||||
return jsonOk({ removed: params.capability })
|
||||
}
|
||||
|
||||
async function handleBuiltinInspect(params: any, request: Request, env: RouterEnv): Promise<Response> {
|
||||
if (!params.capability) {
|
||||
return jsonError(400, 'capability field is required')
|
||||
}
|
||||
|
||||
const result = await env.backend.inspect(params.capability)
|
||||
if (!result) {
|
||||
return jsonError(404, 'Capability not found')
|
||||
}
|
||||
return jsonOk(result)
|
||||
}
|
||||
|
||||
async function handleBuiltinList(params: any, env: RouterEnv): Promise<Response> {
|
||||
const result = await env.backend.query({
|
||||
mode: 'explore',
|
||||
limit: params.limit
|
||||
})
|
||||
return jsonOk(result)
|
||||
}
|
||||
|
||||
async function handleBuiltinStatus(env: RouterEnv): Promise<Response> {
|
||||
const status = await env.backend.status()
|
||||
return jsonOk(status)
|
||||
}
|
||||
|
||||
async function handleInvoke(
|
||||
capability: string,
|
||||
request: Request,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, test, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('Unified Invoke API', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: (workerId, request) => {
|
||||
// Mock external capability responses
|
||||
return new Response(JSON.stringify({ result: 'external success' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
// Set unified deploy token
|
||||
await auth.setToken('valid-token')
|
||||
})
|
||||
|
||||
const makeRequest = (method: string, path: string, options?: {
|
||||
body?: unknown
|
||||
token?: string
|
||||
headers?: Record<string, string>
|
||||
}): Request => {
|
||||
const url = `https://sigil.shazhou.workers.dev${path}`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
}
|
||||
|
||||
if (options?.token) {
|
||||
headers['Authorization'] = `Bearer ${options.token}`
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (options?.body !== undefined) {
|
||||
init.body = JSON.stringify(options.body)
|
||||
}
|
||||
|
||||
return new Request(url, init)
|
||||
}
|
||||
|
||||
// Test builtin capabilities
|
||||
describe('Builtin Capabilities', () => {
|
||||
test('sigil.discover - query capabilities', async () => {
|
||||
// Deploy a test capability first
|
||||
await pool.deploy({
|
||||
name: 'hello',
|
||||
code: "export default { fetch() { return new Response('hello') } }",
|
||||
type: 'normal'
|
||||
})
|
||||
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'sigil.discover',
|
||||
params: {
|
||||
q: 'hello',
|
||||
limit: 5
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.items).toBeInstanceOf(Array)
|
||||
expect(result.total).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('sigil.deploy - deploy capability', async () => {
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'sigil.deploy',
|
||||
params: {
|
||||
name: 'test-cap',
|
||||
execute: 'return "hello world"',
|
||||
type: 'normal'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(201)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.capability).toBe('test-cap')
|
||||
})
|
||||
|
||||
test('sigil.remove - remove capability', async () => {
|
||||
// Deploy a capability first
|
||||
await pool.deploy({
|
||||
name: 'test-cap',
|
||||
code: "export default { fetch() { return new Response('test') } }",
|
||||
type: 'normal'
|
||||
})
|
||||
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'sigil.remove',
|
||||
params: {
|
||||
capability: 'test-cap'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.removed).toBe('test-cap')
|
||||
})
|
||||
|
||||
test('sigil.inspect - inspect capability', async () => {
|
||||
// Deploy a capability first
|
||||
await pool.deploy({
|
||||
name: 'hello',
|
||||
code: "export default { fetch() { return new Response('hello') } }",
|
||||
type: 'normal'
|
||||
})
|
||||
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'sigil.inspect',
|
||||
params: {
|
||||
capability: 'hello'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.capability).toBe('hello')
|
||||
expect(result.type).toBe('normal')
|
||||
})
|
||||
|
||||
test('sigil.list - list all capabilities', async () => {
|
||||
// Deploy some test capabilities
|
||||
await pool.deploy({
|
||||
name: 'cap1',
|
||||
code: "export default { fetch() { return new Response('cap1') } }",
|
||||
type: 'normal'
|
||||
})
|
||||
await pool.deploy({
|
||||
name: 'cap2',
|
||||
code: "export default { fetch() { return new Response('cap2') } }",
|
||||
type: 'persistent'
|
||||
})
|
||||
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'sigil.list',
|
||||
params: {
|
||||
limit: 10
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.items).toBeInstanceOf(Array)
|
||||
expect(result.items.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('sigil.status - get system status', async () => {
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'sigil.status',
|
||||
params: {}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result).toHaveProperty('backend')
|
||||
expect(result).toHaveProperty('total_slots')
|
||||
})
|
||||
|
||||
test('unknown builtin capability should return 404', async () => {
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'sigil.unknown',
|
||||
params: {}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(404)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.error).toContain('Builtin capability not found')
|
||||
})
|
||||
})
|
||||
|
||||
// Test external capabilities
|
||||
describe('External Capabilities', () => {
|
||||
test('should invoke external capability', async () => {
|
||||
// Deploy a capability first
|
||||
await pool.deploy({
|
||||
name: 'external-cap',
|
||||
code: "export default { fetch() { return new Response(JSON.stringify({result: 'external success'}), {headers: {'Content-Type': 'application/json'}}) } }",
|
||||
type: 'normal'
|
||||
})
|
||||
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'external-cap',
|
||||
params: {
|
||||
input: 'test data'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.result).toBe('external success')
|
||||
})
|
||||
|
||||
test('external capability not found should return error', async () => {
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
capability: 'missing-cap',
|
||||
params: {}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
})
|
||||
|
||||
// Test error cases
|
||||
describe('Error Cases', () => {
|
||||
test('missing capability field should return 400', async () => {
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
token: 'valid-token',
|
||||
body: {
|
||||
params: { test: 'data' }
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.error).toBe('capability field is required')
|
||||
})
|
||||
|
||||
test('missing auth should return 401', async () => {
|
||||
const request = makeRequest('POST', '/_api/invoke', {
|
||||
body: {
|
||||
capability: 'sigil.status',
|
||||
params: {}
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
test('invalid JSON should return 400', async () => {
|
||||
const request = new Request('https://sigil.shazhou.workers.dev/_api/invoke', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer valid-token'
|
||||
},
|
||||
body: 'invalid json'
|
||||
})
|
||||
|
||||
const response = await handleRequest(request, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const result = await response.json()
|
||||
expect(result.error).toBe('Invalid JSON in request body')
|
||||
})
|
||||
})
|
||||
|
||||
// Test compatibility with existing routes
|
||||
describe('Backward Compatibility', () => {
|
||||
test('existing routes should still work', async () => {
|
||||
// Test old health route
|
||||
const healthRequest = makeRequest('GET', '/_health')
|
||||
const healthResponse = await handleRequest(healthRequest, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(healthResponse.status).toBe(200)
|
||||
|
||||
// Test old query route
|
||||
const queryRequest = makeRequest('GET', '/_api/query?q=test', { token: 'valid-token' })
|
||||
const queryResponse = await handleRequest(queryRequest, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(queryResponse.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user