openapi.ts•13.8 kB
import { ScriptFilterOptions } from "../../core/src/ast"
import {
deleteUndefinedValues,
ensureHeadSlash,
trimTrailingSlash,
} from "../../core/src/cleaners"
import { genaiscriptDebug } from "../../core/src/debug"
import { nodeTryReadPackage } from "../../core/src/nodepackage"
import { toStrictJSONSchema } from "../../core/src/schema"
import { logError, logVerbose, logWarn } from "../../core/src/util"
import { RemoteOptions, applyRemoteOptions } from "./remote"
import { startProjectWatcher } from "./watch"
import type { FastifyInstance, FastifyRequest } from "fastify"
import { findOpenPort } from "./port"
import { OPENAPI_SERVER_PORT } from "../../core/src/constants"
import { CORE_VERSION } from "../../core/src/version"
import { run } from "./api"
import { errorMessage } from "../../core/src/error"
import { PromptScriptRunOptions } from "./main"
import { ensureDotGenaiscriptPath } from "../../core/src/workdir"
import { uniq } from "es-toolkit"
const dbg = genaiscriptDebug("openapi")
const dbgError = dbg.extend("error")
const dbgHandlers = dbg.extend("handlers")
export async function startOpenAPIServer(
options?: PromptScriptRunOptions &
ScriptFilterOptions &
RemoteOptions & {
port?: string
cors?: string
network?: boolean
startup?: string
route?: string
}
) {
logVerbose(`web api server: starting...`)
await ensureDotGenaiscriptPath()
await applyRemoteOptions(options)
const {
startup,
cors,
network,
remote,
remoteBranch,
remoteForce,
remoteInstall,
groups,
ids,
...runOptions
} = options || {}
const serverHost = network ? "0.0.0.0" : "127.0.0.1"
const route = ensureHeadSlash(trimTrailingSlash(options?.route || "/api"))
const docsRoute = `${route}/docs`
dbg(`route: %s`, route)
dbg(`server host: %s`, serverHost)
dbg(`run options: %O`, runOptions)
const port = await findOpenPort(OPENAPI_SERVER_PORT, options)
const watcher = await startProjectWatcher(options)
logVerbose(`openapi server: watching ${watcher.cwd}`)
const createFastify = (await import("fastify")).default
const swagger = (await import("@fastify/swagger")).default
const swaggerUi = (await import("@fastify/swagger-ui")).default
const swaggerCors = cors
? (await import("@fastify/cors")).default
: undefined
let fastifyController: AbortController | undefined
let fastify: FastifyInstance | undefined
const stopServer = async () => {
const s = fastifyController
const f = fastify
fastifyController = undefined
fastify = undefined
if (s) {
try {
logVerbose(`stopping watcher...`)
s.abort()
} catch (e) {
dbg(e)
}
}
if (f) {
try {
logVerbose(`stopping server...`)
await f.close()
} catch (e) {
dbg(e)
}
}
}
const startServer = async () => {
await stopServer()
logVerbose(`starting server...`)
const tools = (await watcher.scripts()).sort((l, r) =>
l.id.localeCompare(r.id)
)
fastifyController = new AbortController()
fastify = createFastify({ logger: false })
if (cors)
fastify.register(swaggerCors, {
origin: cors,
methods: ["GET", "POST"],
allowedHeaders: ["Content-Type"],
})
// infer server metadata from package.json
const {
name,
description = "GenAIScript OpenAPI Server",
version = "0.0.0",
author,
license,
homepage,
displayName,
} = (await nodeTryReadPackage()) || {}
const operationPrefix = ""
// Register the OpenAPI documentation plugin (Swagger for OpenAPI 3.x)
await fastify.register(swagger, {
openapi: {
openapi: "3.1.1",
info: deleteUndefinedValues({
title: displayName || name,
description,
version,
contact: author ? { name: author } : undefined,
license: license
? {
name: license,
}
: undefined,
}),
externalDocs: homepage
? {
url: homepage,
description: "Homepage",
}
: undefined,
servers: [
{
url: `http://127.0.0.1:${port}`,
description: "GenAIScript server",
},
{
url: `http://localhost:${port}`,
description: "GenAIScript server",
},
{
url: `http://${serverHost}:${port}`,
description: "GenAIScript server",
},
],
tags: uniq([
"default",
...tools.map(({ group }) => group).filter(Boolean),
]).map((name) => ({ name })),
},
})
// Dynamically create a POST route for each tool in the tools list
const routes = new Set<string>([docsRoute])
for (const tool of tools) {
const {
id,
accept,
inputSchema,
title: summary,
description,
group,
} = tool
const scriptSchema = (inputSchema?.properties
.script as JSONSchemaObject) || {
type: "object",
properties: {},
}
const bodySchema = {
type: "object",
properties: deleteUndefinedValues({
...(scriptSchema?.properties || {}),
files:
accept !== "none"
? {
type: "array",
items: {
type: "object",
properties: {
filename: {
type: "string",
description: `Filename of the file. Accepts ${accept || "*"}.`,
},
content: {
type: "string",
description:
"Content of the file. Use 'base64' encoding for binary files.",
},
encoding: {
type: "string",
description:
"Encoding of the file. Binary files should use 'base64'.",
enum: ["base64"],
},
type: {
type: "string",
description:
"MIME type of the file",
},
},
required: ["filename", "content"],
},
}
: undefined,
}),
required: scriptSchema?.required || [],
}
if (!description)
logWarn(`${id}: operation must have a description`)
if (!group) logWarn(`${id}: operation must have a group`)
const operationId = `${operationPrefix}${id}`
const schema = deleteUndefinedValues({
operationId,
summary,
description,
tags: [tool.group || "default"].filter(Boolean),
body: toStrictJSONSchema(bodySchema, { defaultOptional: true }),
response: {
200: toStrictJSONSchema(
{
type: "object",
properties: deleteUndefinedValues({
error: {
type: "string",
description: "Error message",
},
text: {
type: "string",
description: "Output text",
},
data: tool.responseSchema
? toStrictJSONSchema(tool.responseSchema, {
defaultOptional: true,
})
: undefined,
uncertainty: {
type: "number",
description:
"Uncertainty of the response, between 0 and 1",
},
perplexity: {
type: "number",
description:
"Perplexity of the response, lower is better",
},
}),
},
{ defaultOptional: true }
),
},
400: {
type: "object",
properties: {
error: {
type: "string",
description: "Error message",
},
},
},
500: {
type: "object",
properties: {
error: {
type: "string",
description: "Error message",
},
},
},
})
const toolPath = id.replace(/[^a-z\-_]+/gi, "_").replace(/_+$/, "")
const url = `${route}/${toolPath}`
if (routes.has(url)) {
logError(`duplicate route: ${url} for tool ${id}, skipping`)
continue
}
dbg(`script %s: %s\n%O`, id, url, schema)
routes.add(url)
const handler = async (request: FastifyRequest) => {
const { files = [], ...bodyRest } = (request.body || {}) as any
dbgHandlers(`query: %O`, request.query)
dbgHandlers(`body: %O`, bodyRest)
const vars = { ...((request.query as any) || {}), ...bodyRest }
dbgHandlers(`vars: %O`, vars)
// TODO: parse query params?
const res = await run(tool.id, [], {
...runOptions,
workspaceFiles: files || [],
vars: vars,
runTrace: false,
outputTrace: false,
})
if (!res) throw new Error("Internal Server Error")
dbgHandlers(`res: %s`, res.status)
if (res.error) {
dbgHandlers(`error: %O`, res.error)
throw new Error(errorMessage(res.error))
}
return deleteUndefinedValues({
...res,
})
}
fastify.post(url, { schema }, async (request) => {
dbgHandlers(`post %s %O`, tool.id, request.body)
return await handler(request)
})
}
await fastify.register(swaggerUi, {
routePrefix: docsRoute,
})
// Global error handler for uncaught errors and validation issues
fastify.setErrorHandler((error, request, reply) => {
dbgError(`%s %s %O`, request.method, request.url, error)
if (error.validation) {
reply.status(400).send({
error: error.message,
})
} else {
reply.status(error.statusCode ?? 500).send({
error: `Internal Server Error - ${error.message ?? "An unexpected error occurred"}`,
})
}
})
console.log(`GenAIScript OpenAPI v${CORE_VERSION}`)
console.log(`│ API http://localhost:${port}${route}/`)
console.log(`| Console UI: http://localhost:${port}${route}/docs`)
console.log(
`| OpenAPI Spec: http://localhost:${port}${route}/docs/json`
)
await fastify.listen({
port,
host: serverHost,
signal: fastifyController.signal,
})
}
if (startup) {
logVerbose(`startup script: ${startup}`)
await run(startup, [], {})
}
// start watcher
watcher.addEventListener("change", startServer)
await startServer()
}