This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// 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`
|
||||
Reference in New Issue
Block a user