206 lines
7.9 KiB
TypeScript
206 lines
7.9 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The OpenAPI 3.1 document, generated from the registry (spec §6.2).
|
|
//
|
|
// There is no second description of a procedure anywhere: the name, the kind, the scope, the
|
|
// permission and both zod schemas come from the manifests, and zod 4 converts a schema to JSON
|
|
// Schema itself. The committed openapi.json is this function's output, and test/openapi.test.ts
|
|
// fails when the two differ -- so a changed input schema is a visible API change in review.
|
|
|
|
import type { Registry } from "@conusai-kit/core"
|
|
import { z } from "zod"
|
|
|
|
export const API_PREFIX = "/api/v1"
|
|
|
|
/** The problem+json model every refusal uses (kit-core's errors.ts). */
|
|
const problemSchema = {
|
|
type: "object",
|
|
required: ["type", "title", "status", "code"],
|
|
properties: {
|
|
type: { type: "string", description: "An app-relative URI reference, /problems/<code>." },
|
|
title: { type: "string" },
|
|
status: { type: "integer" },
|
|
code: { type: "string" },
|
|
detail: { type: "string" },
|
|
errors: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
required: ["path", "message"],
|
|
properties: { path: { type: "string" }, message: { type: "string" } },
|
|
},
|
|
},
|
|
retryAfter: { type: "integer", description: "Seconds, for 429 and 503." },
|
|
},
|
|
} as const
|
|
|
|
const problemResponse = (description: string) => ({
|
|
description,
|
|
content: { "application/problem+json": { schema: { $ref: "#/components/schemas/Problem" } } },
|
|
})
|
|
|
|
const jsonSchemaOf = (schema: z.ZodType, io: "input" | "output"): unknown =>
|
|
z.toJSONSchema(schema, { target: "draft-2020-12", io, unrepresentable: "any", reused: "inline" })
|
|
|
|
/**
|
|
* What a procedure with a streamed body (kit-core `.stream(field)`) is on the wire: a `PUT` whose
|
|
* body is the bytes and whose query parameters are the rest of the input. The schema of the input
|
|
* still comes from the contract; the body field is the one that is not a parameter.
|
|
*/
|
|
function streamedOperation(def: { input: z.ZodType; stream?: string | undefined }) {
|
|
if (def.stream === undefined) return undefined
|
|
const input = jsonSchemaOf(def.input, "input") as {
|
|
properties?: Record<string, unknown>
|
|
required?: string[]
|
|
}
|
|
const required = new Set(input.required ?? [])
|
|
return {
|
|
parameters: Object.entries(input.properties ?? {})
|
|
.filter(([name]) => name !== def.stream)
|
|
.map(([name, schema]) => ({ name, in: "query", required: required.has(name), schema })),
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
|
description: "The bytes, sent as they are. Their length must be the one the upload declared.",
|
|
},
|
|
}
|
|
}
|
|
|
|
export interface OpenApiInfo {
|
|
title: string
|
|
version: string
|
|
description?: string
|
|
}
|
|
|
|
export function openapiDocument(registry: Registry, info: OpenApiInfo): Record<string, unknown> {
|
|
const paths: Record<string, unknown> = {}
|
|
for (const name of [...registry.procedures.keys()].sort()) {
|
|
const entry = registry.procedures.get(name)
|
|
if (!entry) continue
|
|
const { def, module } = entry
|
|
const authenticated = def.scope !== "public"
|
|
const streamed = streamedOperation(def)
|
|
const downloadInput = def.download
|
|
? (jsonSchemaOf(def.input, "input") as { properties?: Record<string, unknown>; required?: string[] })
|
|
: undefined
|
|
const downloadRequired = new Set(downloadInput?.required ?? [])
|
|
const responseContent = def.download
|
|
? { "application/octet-stream": { schema: { type: "string", format: "binary" } } }
|
|
: { "application/json": { schema: jsonSchemaOf(def.output, "output") } }
|
|
paths[`${API_PREFIX}/${name}`] = {
|
|
[def.download ? "get" : streamed ? "put" : "post"]: {
|
|
operationId: name,
|
|
tags: [module],
|
|
summary: `${def.kind} procedure on the ${def.scope} scope`,
|
|
description: [
|
|
`Kind: ${def.kind}. Scope: ${def.scope}. Permission: ${def.permission}.`,
|
|
def.idempotent ? "Takes an Idempotency-Key header." : undefined,
|
|
def.audit ? `Audit event: ${def.audit}.` : undefined,
|
|
def.usage ? `Usage meter: ${def.usage}.` : undefined,
|
|
def.tool ? "Exposed as an MCP tool." : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
...(authenticated ? { security: [{ session: [] }, { bearer: [] }, { apiKey: [] }] } : { security: [{}] }),
|
|
parameters: [
|
|
...(def.idempotent
|
|
? [
|
|
{
|
|
name: "Idempotency-Key",
|
|
in: "header",
|
|
required: true,
|
|
schema: { type: "string", minLength: 8, maxLength: 200 },
|
|
description:
|
|
"The first answer for this key is replayed, so a retry is a retry and not a second write.",
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
name: "X-CSRF-Token",
|
|
in: "header",
|
|
required: false,
|
|
schema: { type: "string" },
|
|
description: "Required when the credential is the session cookie; not used by bearer or API-key callers.",
|
|
},
|
|
...(streamed?.parameters ?? []),
|
|
],
|
|
...(!def.download
|
|
? {
|
|
requestBody: streamed?.requestBody ?? {
|
|
required: true,
|
|
content: { "application/json": { schema: jsonSchemaOf(def.input, "input") } },
|
|
},
|
|
}
|
|
: {}),
|
|
...(def.download
|
|
? {
|
|
parameters: [
|
|
...(def.idempotent
|
|
? [
|
|
{
|
|
name: "Idempotency-Key",
|
|
in: "header",
|
|
required: true,
|
|
schema: { type: "string", minLength: 8, maxLength: 200 },
|
|
description: "The first answer for this key is replayed.",
|
|
},
|
|
]
|
|
: []),
|
|
...Object.entries(downloadInput?.properties ?? {}).map(([name, schema]) => ({
|
|
name,
|
|
in: "query",
|
|
required: downloadRequired.has(name),
|
|
schema,
|
|
})),
|
|
],
|
|
}
|
|
: {}),
|
|
responses: {
|
|
"200": {
|
|
description: def.download
|
|
? "The file bytes, streamed with the stored filename."
|
|
: "The procedure's output.",
|
|
content: responseContent,
|
|
},
|
|
"400": problemResponse("The request could not be read."),
|
|
"401": problemResponse("No credential, or one that is no longer valid."),
|
|
"404": problemResponse(
|
|
"No such procedure in this scope: another tenant's ids answer the same way as a missing permission.",
|
|
),
|
|
"409": problemResponse("A conflict, such as a taken slug or a stale version."),
|
|
"422": problemResponse("The input does not match the procedure's schema."),
|
|
"429": problemResponse("Rate limited; Retry-After says when."),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return {
|
|
openapi: "3.1.0",
|
|
info,
|
|
servers: [{ url: "/", description: "This deployment." }],
|
|
paths,
|
|
components: {
|
|
schemas: { Problem: problemSchema },
|
|
securitySchemes: {
|
|
session: {
|
|
type: "apiKey",
|
|
in: "cookie",
|
|
name: "conusai_session",
|
|
description: "The sealed session cookie a browser holds.",
|
|
},
|
|
bearer: {
|
|
type: "http",
|
|
scheme: "bearer",
|
|
bearerFormat: "JWT",
|
|
description: "An OIDC access token for this app's audience.",
|
|
},
|
|
apiKey: { type: "http", scheme: "bearer", description: "An organization API key, cak_<prefix>_<secret>." },
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/** The document as it is served and as it is committed: two spaces, one trailing newline. */
|
|
export const openapiJson = (registry: Registry, info: OpenApiInfo): string =>
|
|
`${JSON.stringify(openapiDocument(registry, info), null, 2)}\n`
|