506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
import { OpenApi } from "effect/unstable/httpapi"
|
|
import { OpenCodeHttpApi } from "./api"
|
|
|
|
type OpenApiParameter = {
|
|
name: string
|
|
in: string
|
|
required?: boolean
|
|
schema?: OpenApiSchema
|
|
}
|
|
|
|
type OpenApiOperation = {
|
|
parameters?: OpenApiParameter[]
|
|
responses?: Record<string, OpenApiResponse>
|
|
requestBody?: {
|
|
required?: boolean
|
|
content?: Record<string, { schema?: OpenApiSchema }>
|
|
}
|
|
security?: unknown
|
|
}
|
|
|
|
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
|
|
|
|
type OpenApiSpec = {
|
|
components?: {
|
|
schemas?: Record<string, OpenApiSchema>
|
|
securitySchemes?: Record<string, unknown>
|
|
}
|
|
paths?: Record<string, OpenApiPathItem>
|
|
}
|
|
|
|
type OpenApiSchema = {
|
|
$ref?: string
|
|
additionalProperties?: OpenApiSchema | boolean
|
|
allOf?: OpenApiSchema[]
|
|
anyOf?: OpenApiSchema[]
|
|
description?: string
|
|
enum?: Array<string | boolean>
|
|
items?: OpenApiSchema
|
|
maximum?: number
|
|
minimum?: number
|
|
oneOf?: OpenApiSchema[]
|
|
prefixItems?: OpenApiSchema[]
|
|
properties?: Record<string, OpenApiSchema>
|
|
required?: string[]
|
|
type?: string
|
|
}
|
|
|
|
type OpenApiResponse = {
|
|
description?: string
|
|
content?: Record<string, { schema?: OpenApiSchema }>
|
|
}
|
|
|
|
// Instance routes use middleware for directory/workspace resolution, but HttpApi
|
|
// doesn't surface middleware query params in the spec. Inject them explicitly.
|
|
const InstanceQueryParameters = [
|
|
{
|
|
name: "directory",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string" },
|
|
},
|
|
{
|
|
name: "workspace",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string" },
|
|
},
|
|
] satisfies OpenApiParameter[]
|
|
|
|
// Query schemas describe decoded Effect values, but the generated SDK needs the
|
|
// public call shape. These keep SDK callers passing numbers/booleans while the
|
|
// server still decodes string query params at runtime.
|
|
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
|
|
const QueryBooleanParameters = new Set(["roots", "archived"])
|
|
const QueryParameterSchemas = {
|
|
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
|
|
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
|
} satisfies Record<string, OpenApiSchema>
|
|
|
|
const LegacyComponentDescriptions = {
|
|
LogLevel: "Log level",
|
|
ServerConfig: "Server configuration for opencode serve and web commands",
|
|
LayoutConfig: "@deprecated Always uses stretch layout.",
|
|
} satisfies Record<string, string>
|
|
|
|
function matchLegacyOpenApi(input: Record<string, unknown>) {
|
|
const spec = input as OpenApiSpec
|
|
|
|
// Effect's multi-document JSON Schema deduplicator can produce self-referencing
|
|
// component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition
|
|
// of X itself) when the same AST node appears both as a standalone endpoint
|
|
// payload and inside an annotated union arm. Resolve these by inlining the
|
|
// actual schema from any parent union that references them.
|
|
fixSelfReferencingComponents(spec)
|
|
|
|
// Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI,
|
|
// but the legacy SDK expected plain `T` for optional fields. Strip null
|
|
// from all component schemas so both request and response types match.
|
|
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
|
spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema))
|
|
}
|
|
normalizeComponentNames(spec)
|
|
collapseDuplicateComponents(spec)
|
|
applyLegacySchemaOverrides(spec)
|
|
normalizeComponentDescriptions(spec)
|
|
addLegacyErrorSchemas(spec)
|
|
delete spec.components?.schemas?.Unauthorized
|
|
delete spec.components?.schemas?.EffectHttpApiErrorBadRequest
|
|
delete spec.components?.schemas?.EffectHttpApiErrorNotFound
|
|
delete spec.components?.schemas?.effect_HttpApiError_BadRequest
|
|
delete spec.components?.schemas?.effect_HttpApiError_NotFound
|
|
delete spec.components?.securitySchemes
|
|
|
|
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
|
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
|
|
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
|
const operation = item[method]
|
|
if (!operation) continue
|
|
if (operation.requestBody) {
|
|
// Hono's generated OpenAPI never marked request bodies as required. Keep
|
|
// that SDK surface stable during the HttpApi migration.
|
|
delete operation.requestBody.required
|
|
const body = operation.requestBody.content?.["application/json"]
|
|
if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema))
|
|
if (path === "/experimental/workspace" && method === "post") {
|
|
// Workspace creation fields `branch` and `extra` are Schema.NullOr —
|
|
// genuinely nullable, not just optional. Re-add the null that the
|
|
// component-level strip above removed.
|
|
const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace(
|
|
"#/components/schemas/",
|
|
"",
|
|
)
|
|
const properties = ref
|
|
? spec.components?.schemas?.[ref]?.properties
|
|
: operation.requestBody.content?.["application/json"]?.schema?.properties
|
|
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
|
|
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
|
|
}
|
|
}
|
|
for (const response of Object.values(operation.responses ?? {})) {
|
|
for (const content of Object.values(response.content ?? {})) {
|
|
if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema))
|
|
}
|
|
}
|
|
// Hono applied auth as runtime middleware outside OpenAPI metadata, so the
|
|
// legacy SDK did not expose auth schemes or generated 401 error unions.
|
|
delete operation.security
|
|
delete operation.responses?.["401"]
|
|
normalizeLegacyErrorResponses(operation)
|
|
normalizeLegacyOperation(operation, path, method)
|
|
if ((path === "/event" || path === "/global/event") && method === "get") {
|
|
// HttpApi has no first-class SSE response schema, and these handlers are
|
|
// raw/streaming routes. Document the actual wire protocol explicitly.
|
|
operation.responses!["200"] = {
|
|
description: "Event stream",
|
|
content: {
|
|
"text/event-stream": {
|
|
schema:
|
|
path === "/event"
|
|
? { $ref: "#/components/schemas/Event" }
|
|
: { $ref: "#/components/schemas/GlobalEvent" },
|
|
},
|
|
},
|
|
}
|
|
}
|
|
if (!isInstanceRoute) continue
|
|
operation.parameters = [
|
|
...InstanceQueryParameters,
|
|
...(operation.parameters ?? []).filter(
|
|
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
|
|
),
|
|
]
|
|
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
|
}
|
|
}
|
|
return input
|
|
}
|
|
|
|
function addLegacyErrorSchemas(spec: OpenApiSpec) {
|
|
if (!spec.components?.schemas) return
|
|
spec.components.schemas.BadRequestError = {
|
|
type: "object",
|
|
required: ["data", "errors", "success"],
|
|
properties: {
|
|
data: {},
|
|
errors: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
additionalProperties: {},
|
|
},
|
|
},
|
|
success: { type: "boolean", enum: [false] },
|
|
},
|
|
}
|
|
spec.components.schemas.NotFoundError = {
|
|
type: "object",
|
|
required: ["name", "data"],
|
|
properties: {
|
|
name: { type: "string", enum: ["NotFoundError"] },
|
|
data: {
|
|
type: "object",
|
|
required: ["message"],
|
|
properties: {
|
|
message: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function collapseDuplicateComponents(spec: OpenApiSpec) {
|
|
const schemas = spec.components?.schemas
|
|
if (!schemas) return
|
|
for (const name of Object.keys(schemas)) {
|
|
const base = name.replace(/\d+$/, "")
|
|
if (base === name || !schemas[base]) continue
|
|
if (stableSchema(schemas[name], schemas) !== stableSchema(schemas[base], schemas)) continue
|
|
rewriteRefs(spec, name, base)
|
|
delete schemas[name]
|
|
}
|
|
}
|
|
|
|
function normalizeComponentNames(spec: OpenApiSpec) {
|
|
const schemas = spec.components?.schemas
|
|
if (!schemas) return
|
|
for (const name of Object.keys(schemas)) {
|
|
const next = componentTypeName(name)
|
|
if (next === name) continue
|
|
if (schemas[next]) {
|
|
if (stableSchema(schemas[name], schemas) === stableSchema(schemas[next], schemas)) {
|
|
rewriteRefs(spec, name, next)
|
|
delete schemas[name]
|
|
}
|
|
continue
|
|
}
|
|
schemas[next] = schemas[name]
|
|
rewriteRefs(spec, name, next)
|
|
delete schemas[name]
|
|
}
|
|
}
|
|
|
|
function componentTypeName(name: string) {
|
|
if (!name.includes(".")) return name
|
|
return name
|
|
.split(".")
|
|
.filter((part) => !/^\d+$/.test(part))
|
|
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
.join("")
|
|
}
|
|
|
|
function applyLegacySchemaOverrides(spec: OpenApiSpec) {
|
|
const schemas = spec.components?.schemas
|
|
if (!schemas) return
|
|
if (schemas.AgentConfig) schemas.AgentConfig.additionalProperties = {}
|
|
if (schemas.Command?.properties?.template) schemas.Command.properties.template = { type: "string" }
|
|
if (schemas.Workspace?.properties) {
|
|
schemas.Workspace.properties.branch = nullable(schemas.Workspace.properties.branch)
|
|
schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory)
|
|
schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra)
|
|
}
|
|
if (schemas.GlobalSession?.properties?.project)
|
|
schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project)
|
|
const providerOptions = schemas.ProviderConfig?.properties?.options
|
|
if (providerOptions) providerOptions.additionalProperties = {}
|
|
const model = schemas.ProviderConfig?.properties?.models?.additionalProperties
|
|
const variants = typeof model === "object" ? model.properties?.variants?.additionalProperties : undefined
|
|
if (variants && typeof variants === "object") variants.additionalProperties = {}
|
|
const syncInfo = schemas.SyncEventSessionUpdated?.properties?.data?.properties?.info
|
|
if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties)
|
|
}
|
|
|
|
function normalizeComponentDescriptions(spec: OpenApiSpec) {
|
|
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
|
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions]
|
|
if (description) {
|
|
schema.description = description
|
|
continue
|
|
}
|
|
delete schema.description
|
|
}
|
|
}
|
|
|
|
function makePropertiesNullable(properties: Record<string, OpenApiSchema>) {
|
|
for (const [key, value] of Object.entries(properties)) {
|
|
if (key === "share" && value.properties?.url) {
|
|
value.properties.url = nullable(value.properties.url)
|
|
continue
|
|
}
|
|
if (key === "time" && value.properties) {
|
|
makePropertiesNullable(value.properties)
|
|
continue
|
|
}
|
|
properties[key] = nullable(value)
|
|
}
|
|
}
|
|
|
|
function nullable(schema: OpenApiSchema): OpenApiSchema {
|
|
if (flattenOptions(schema.anyOf ?? schema.oneOf)?.some((item) => item.type === "null")) return schema
|
|
return { anyOf: [schema, { type: "null" }] }
|
|
}
|
|
|
|
function stableSchema(input: unknown, schemas: Record<string, OpenApiSchema>): string {
|
|
return JSON.stringify(canonicalizeSchema(input, schemas))
|
|
}
|
|
|
|
function canonicalizeSchema(input: unknown, schemas: Record<string, OpenApiSchema>): unknown {
|
|
if (Array.isArray(input)) return input.map((item) => canonicalizeSchema(item, schemas))
|
|
if (!input || typeof input !== "object") return input
|
|
const schema = input as OpenApiSchema
|
|
if (schema.$ref) return { $ref: canonicalRef(schema.$ref, schemas) }
|
|
return Object.fromEntries(
|
|
Object.entries(input)
|
|
.filter(([key]) => key !== "description")
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([key, value]) => [key, canonicalizeSchema(value, schemas)]),
|
|
)
|
|
}
|
|
|
|
function canonicalRef(ref: string, schemas: Record<string, OpenApiSchema>) {
|
|
const name = ref.replace("#/components/schemas/", "")
|
|
const base = name.replace(/\d+$/, "")
|
|
if (base !== name && schemas[base]) return `#/components/schemas/${base}`
|
|
return ref
|
|
}
|
|
|
|
function rewriteRefs(input: unknown, from: string, to: string): void {
|
|
if (Array.isArray(input)) {
|
|
for (const item of input) rewriteRefs(item, from, to)
|
|
return
|
|
}
|
|
if (!input || typeof input !== "object") return
|
|
const schema = input as OpenApiSchema
|
|
if (schema.$ref === `#/components/schemas/${from}`) schema.$ref = `#/components/schemas/${to}`
|
|
for (const value of Object.values(input)) rewriteRefs(value, from, to)
|
|
}
|
|
|
|
function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
|
|
if (operation.responses?.["400"] && isBuiltInErrorResponse(operation.responses["400"], "BadRequest")) {
|
|
operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError")
|
|
}
|
|
if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) {
|
|
operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError")
|
|
}
|
|
}
|
|
|
|
function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) {
|
|
if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"]
|
|
if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"]
|
|
if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return
|
|
const response = operation.responses?.["200"]?.content?.["application/json"]
|
|
if (!response) return
|
|
response.schema = {
|
|
type: "object",
|
|
required: ["info", "parts"],
|
|
properties: {
|
|
info: { $ref: "#/components/schemas/AssistantMessage" },
|
|
parts: {
|
|
type: "array",
|
|
items: { $ref: "#/components/schemas/Part" },
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function isRefResponse(response: OpenApiResponse, name: string) {
|
|
return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}`
|
|
}
|
|
|
|
function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") {
|
|
return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`)
|
|
}
|
|
|
|
function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse {
|
|
return {
|
|
description,
|
|
content: {
|
|
"application/json": {
|
|
schema: { $ref: `#/components/schemas/${name}` },
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI
|
|
* generation bug where annotated union arms that share AST nodes with other
|
|
* endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X.
|
|
*
|
|
* Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf`
|
|
* that references the broken component, then inlining that schema.
|
|
*/
|
|
function fixSelfReferencingComponents(spec: OpenApiSpec) {
|
|
const schemas = spec.components?.schemas
|
|
if (!schemas) return
|
|
const selfRefs = new Set<string>()
|
|
for (const [name, schema] of Object.entries(schemas)) {
|
|
if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name)
|
|
}
|
|
if (selfRefs.size === 0) return
|
|
// Find a parent union component whose anyOf/oneOf contains a $ref to the
|
|
// broken component — that parent was generated correctly and holds the inline
|
|
// schema we need.
|
|
for (const [, schema] of Object.entries(schemas)) {
|
|
for (const member of schema.anyOf ?? schema.oneOf ?? []) {
|
|
const ref = member.$ref?.replace("#/components/schemas/", "")
|
|
if (!ref || !selfRefs.has(ref)) continue
|
|
// This member's $ref points to a self-referencing component. The member
|
|
// itself is just {$ref:...}, so the actual schema must be resolved from
|
|
// the union. Since the union component was generated before the
|
|
// deduplicator broke things, the inline version lives elsewhere. Generate
|
|
// a fresh spec without the transform to get the correct schema.
|
|
// Simpler approach: look through all paths for an endpoint that uses this
|
|
// schema as a payload (it would have been expanded by the ref-expansion
|
|
// logic above if we ran after that, but we run before). Instead, just
|
|
// delete the broken component — if it's referenced via $ref elsewhere,
|
|
// the ref expansion in the request body loop will inline it anyway.
|
|
}
|
|
}
|
|
// Simplest fix: generate the raw spec (without transform) to get correct schemas
|
|
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
|
|
const rawSchemas = raw.components?.schemas
|
|
if (!rawSchemas) return
|
|
for (const name of selfRefs) {
|
|
if (rawSchemas[name]) schemas[name] = rawSchemas[name]
|
|
}
|
|
}
|
|
|
|
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
|
|
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
|
|
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
|
|
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
|
|
if (options) {
|
|
const withoutNull = options.filter((item) => item.type !== "null")
|
|
if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0])
|
|
if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull)
|
|
if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull)
|
|
}
|
|
if (schema.allOf) {
|
|
const allOf = schema.allOf.map(stripOptionalNull)
|
|
if (schema.type) {
|
|
delete schema.allOf
|
|
for (const item of allOf) Object.assign(schema, item)
|
|
} else {
|
|
schema.allOf = allOf
|
|
}
|
|
}
|
|
if (schema.prefixItems && schema.items) delete schema.prefixItems
|
|
if (schema.items) schema.items = stripOptionalNull(schema.items)
|
|
if (schema.properties) {
|
|
for (const [key, value] of Object.entries(schema.properties)) {
|
|
schema.properties[key] = stripOptionalNull(value)
|
|
}
|
|
}
|
|
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
schema.additionalProperties = stripOptionalNull(schema.additionalProperties)
|
|
}
|
|
return schema
|
|
}
|
|
|
|
function isEmptyObjectUnion(schema: OpenApiSchema) {
|
|
const options = schema.anyOf ?? schema.oneOf
|
|
return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema)
|
|
}
|
|
|
|
function isBareObjectSchema(schema: OpenApiSchema) {
|
|
return schema.type === "object" && !schema.properties && !schema.additionalProperties
|
|
}
|
|
|
|
function isBareArraySchema(schema: OpenApiSchema) {
|
|
return schema.type === "array" && !schema.items && !schema.prefixItems
|
|
}
|
|
|
|
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
|
|
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
|
|
}
|
|
|
|
function normalizeParameter(param: OpenApiParameter, route: string) {
|
|
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
|
|
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
|
|
if (override) {
|
|
param.schema = override
|
|
return
|
|
}
|
|
if (QueryNumberParameters.has(param.name)) {
|
|
param.schema = { type: "number" }
|
|
return
|
|
}
|
|
if (QueryBooleanParameters.has(param.name)) {
|
|
param.schema = {
|
|
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
|
}
|
|
return
|
|
}
|
|
param.schema = stripOptionalNull(param.schema)
|
|
}
|
|
|
|
export const PublicApi = OpenCodeHttpApi.annotateMerge(
|
|
OpenApi.annotations({
|
|
title: "opencode",
|
|
version: "1.0.0",
|
|
description: "opencode api",
|
|
transform: matchLegacyOpenApi,
|
|
}),
|
|
)
|