fix(codegen): stabilize generated contract names (#44000)

This commit is contained in:
Kit Langton
2026-08-21 20:16:27 -04:00
committed by GitHub
parent f69f78ec6b
commit e33d688428
17 changed files with 16245 additions and 16000 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1089,7 +1089,7 @@ export type PtyUpdated = {
data: { info: Pty }
}
export type SessionStatus2 = {
export type SessionStatusUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
@@ -2117,7 +2117,7 @@ export type V2Event =
| FormReplied
| FormCancelled
| WebsearchUpdated
| SessionStatus2
| SessionStatusUpdated
| SessionIdle
| TuiPromptAppend
| TuiCommandExecute
@@ -19,8 +19,8 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).toContain("export type SessionGetOutput = Session.Info")
expect(source).toContain("export type EventSubscribeOutput = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi")
})
+92 -42
View File
@@ -216,12 +216,17 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Constrain
const modules = new Set(["client", "client-error", "index"])
const groups = Array.from(
Map.groupBy(endpoints, (endpoint) => endpoint.group),
([identifier, endpoints], index) => {
([identifier, endpoints]) => {
if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) {
throw new GenerationError({ reason: `Client group name collision: ${identifier}` })
}
const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}`
const module = uniqueModule(base, index, modules)
// Module names derive from the group identifier so unrelated groups never rename.
const sanitized = identifier.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "")
const reserved = /^(aux|client|client-error|con|index|nul|prn|com[1-9]|lpt[1-9])$/i.test(sanitized)
const module = sanitized === "" || reserved ? `group-${sanitized}` : sanitized
if (modules.has(module.toLowerCase())) {
throw new GenerationError({ reason: `Client module name collision: ${module}` })
}
modules.add(module.toLowerCase())
return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints }
},
@@ -363,9 +368,28 @@ function renderEffectShape(
) {
const references = effectTypeReferences(typeReferences)
const imports = new Set<string>()
const endpointTypes = groups.map((group, groupIndex) => {
const endpoints = group.endpoints.map((endpoint, endpointIndex) => {
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
const externalNames = new Set([
"AppApi",
"Effect",
"Stream",
...typeReferences.flatMap((reference) => reference.name.match(/^[A-Za-z_$][A-Za-z0-9_$]*/) ?? []),
...Object.values(outputTypes ?? {}).flatMap((output) => output.name.match(/^[A-Za-z_$][A-Za-z0-9_$]*/) ?? []),
])
const generatedNames = groups.flatMap((group) => [
groupShapeName(group),
...group.endpoints.flatMap((endpoint) => [
...(endpoint.operation.inputMode === "none" ? [] : [`${endpointTypeName(group, endpoint)}Input`]),
`${endpointTypeName(group, endpoint)}Output`,
groupShapeTypeName(group, endpoint),
]),
])
const collision = generatedNames.find((name) => externalNames.has(name))
if (collision !== undefined) {
throw new GenerationError({ reason: `Generated Effect type collides with imported type: ${collision}` })
}
const endpointTypes = groups.map((group) => {
const endpoints = group.endpoints.map((endpoint) => {
const prefix = endpointTypeName(group, endpoint)
const input = endpoint.input
.map((field) => {
const schema = effectInputSchema(endpoint, field)
@@ -530,8 +554,23 @@ function groupShapeName(group: Group) {
return `${identifierPart(group.identifier)}Api`
}
// Generated symbol names derive from group and endpoint identity, never from traversal
// position, so adding an endpoint or group cannot rename unrelated generated code.
// Uniqueness is validated by compile (groupTypeNames/endpointTypeNames).
function groupTypeName(group: Group) {
return identifierPart(group.identifier)
}
function endpointTypeName(group: Group, endpoint: Endpoint) {
return `${groupTypeName(group)}${endpoint.clientPath.map(identifierPart).join("")}`
}
function endpointAdapterName(group: Group, endpoint: Endpoint) {
return `Endpoint${endpointTypeName(group, endpoint)}`
}
function groupShapeTypeName(group: Group, endpoint: Endpoint) {
return `${identifierPart(group.identifier)}${endpoint.clientPath.map(identifierPart).join("")}Operation`
return `${endpointTypeName(group, endpoint)}Operation`
}
function assertPromiseEndpoint(endpoint: Endpoint) {
@@ -585,7 +624,7 @@ function promiseOperations(groups: ReadonlyArray<Group>) {
function renderEffectFiles(groups: ReadonlyArray<Group>): Output["files"] {
return [
...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })),
...groups.map((group) => ({ path: `${group.module}.ts`, content: renderGroup(group) })),
{
path: "client-error.ts",
content:
@@ -610,10 +649,11 @@ function renderImportedEffectFiles(
readonly shapeModule?: string
},
): Output["files"] {
const adapters = groups.map((group, groupIndex) => {
const adapters = groups.map((group) => {
const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]`
const methods = group.endpoints.map((item, endpointIndex) => {
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
const methods = group.endpoints.map((item) => {
const prefix = endpointTypeName(group, item)
const adapter = endpointAdapterName(group, item)
const schemaBySource = {
params: item.params,
query: item.query,
@@ -660,20 +700,22 @@ function renderImportedEffectFiles(
: isOpaquePayload(item)
? `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.identifier)}]>[0]\n`
: ""
return `${declarations}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${output}`
return `${declarations}const ${adapter} = (raw: ${rawGroup}) => (${argument}) => ${output}`
})
const fields = renderClientTree(
group.endpoints,
(_item, endpointIndex) => `Endpoint${groupIndex}_${endpointIndex}(raw)`,
(item) => `${endpointAdapterName(group, item)}(raw)`,
(name, value) => `${JSON.stringify(name)}: ${value}`,
", ",
)
return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${fields} })`
return `${methods.join("\n\n")}\n\nconst adaptGroup${groupTypeName(group)} = (raw: ${rawGroup}) => ({ ${fields} })`
})
const fields = groups.flatMap((group, index) =>
const fields = groups.flatMap((group) =>
group.endpoints[0]?.topLevel
? [`...adaptGroup${index}(raw)`]
: [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`],
? [`...adaptGroup${groupTypeName(group)}(raw)`]
: [
`${JSON.stringify(group.identifier)}: adaptGroup${groupTypeName(group)}(raw[${JSON.stringify(group.sourceIdentifier)}])`,
],
)
const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream"))
const imported = "api" in options
@@ -683,15 +725,24 @@ function renderImportedEffectFiles(
? renderImportedGroup(options.group)
: renderImportedProjection(groups, options.endpoints)
const api = imported ? options.api : "Api"
const adapterNames = new Set(
groups.flatMap((group) => group.endpoints.map((endpoint) => endpointAdapterName(group, endpoint))),
)
const adapterCollision = (projection?.imports ?? [api]).find((name) => adapterNames.has(name))
if (adapterCollision !== undefined) {
throw new GenerationError({
reason: `Generated Effect adapter collides with imported endpoint: ${adapterCollision}`,
})
}
const imports =
projection === undefined
? `import { ${api} } from ${JSON.stringify(options.module)}`
: `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}`
const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : ""
const shapeTypes = groups.flatMap((group, groupIndex) =>
group.endpoints.flatMap((endpoint, endpointIndex) => [
...(endpoint.operation.inputMode === "none" ? [] : [`Endpoint${groupIndex}_${endpointIndex}Input`]),
`Endpoint${groupIndex}_${endpointIndex}Output`,
const shapeTypes = groups.flatMap((group) =>
group.endpoints.flatMap((endpoint) => [
...(endpoint.operation.inputMode === "none" ? [] : [`${endpointTypeName(group, endpoint)}Input`]),
`${endpointTypeName(group, endpoint)}Output`,
]),
)
const shapeImport =
@@ -975,11 +1026,12 @@ function renderClientTree(
}
function identifierPart(value: string) {
return value
const identifier = value
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
.join("")
return /^[A-Za-z_$]/.test(identifier) ? identifier : `_${identifier}`
}
function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, reservedNames: ReadonlySet<string>) {
@@ -1239,14 +1291,6 @@ function promisePath(path: string, input: ReadonlyArray<InputField>, wildcard?:
return `\`${template}${wildcard === undefined ? "" : `\${encodePath(input.${wildcard.name})}`}\``
}
function uniqueModule(base: string, index: number, modules: ReadonlySet<string>) {
if (!modules.has(base.toLowerCase())) return base
const seed = `${base}-${index}`
let suffix = 0
while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++
return `${seed}${suffix === 0 ? "" : `-${suffix}`}`
}
function normalizeTransport(
schema: Schema.Top | undefined,
source: InputField["source"] | "success" | "error",
@@ -1697,10 +1741,10 @@ function streamEffectPortable(schema: Schema.Top) {
return sameEncoding(schema.events.ast, rebuilt.events.ast)
}
function renderGroup(group: Group, groupIndex: number) {
function renderGroup(group: Group) {
const slots: Array<Slot> = []
const adapters: Array<string> = []
const endpointSources = group.endpoints.map((operation, endpointIndex) => {
const endpointSources = group.endpoints.map((operation) => {
const {
endpoint,
errors,
@@ -1710,7 +1754,7 @@ function renderGroup(group: Group, groupIndex: number) {
query: endpointQuery,
successes,
} = operation
const prefix = `Endpoint${endpointIndex}`
const prefix = `Endpoint${operation.clientPath.map(identifierPart).join("")}`
const params = addSlot(endpointParams, `${prefix}Params`)
const query = addSlot(endpointQuery, `${prefix}Query`)
const headers = addSlot(endpointHeaders, `${prefix}Headers`)
@@ -1805,15 +1849,16 @@ function renderGroup(group: Group, groupIndex: number) {
const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema."))
const methods = renderClientTree(
group.endpoints,
(_item, index) => `Endpoint${index}(raw)`,
(item) => `Endpoint${item.clientPath.map(identifierPart).join("")}(raw)`,
(name, value) => `${JSON.stringify(name)}: ${value}`,
", ",
)
const name = groupTypeName(group)
const rawGroup = group.endpoints[0]?.topLevel
? `HttpApiClient.Client<typeof Group${groupIndex}>`
: `HttpApiClient.Client.Group<typeof Group${groupIndex}, never, never>`
? `HttpApiClient.Client<typeof Group${name}>`
: `HttpApiClient.Client.Group<typeof Group${name}, never, never>`
const usesStream = group.endpoints.some((item) => item.operation.success === "stream")
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error.js"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n`
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error.js"\n\n${declarations}\n\nexport const Group${name} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${name} = (raw: RawGroup) => ({ ${methods} })\n`
}
function renderEffectRequestPart(
@@ -1883,15 +1928,20 @@ function renderSchemas(slots: ReadonlyArray<Slot>) {
function renderClient(groups: ReadonlyArray<Group>) {
const imports = groups
.map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`)
.map(
(group) =>
`import { adaptGroup${groupTypeName(group)}, Group${groupTypeName(group)} } from ${JSON.stringify(`./${group.module}`)}`,
)
.join("\n")
const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}`
const fields = groups.flatMap((group, index) => {
const api = `HttpApi.make("generated")${groups.map((group) => `.add(Group${groupTypeName(group)})`).join("")}`
const fields = groups.flatMap((group) => {
if (!group.endpoints[0]?.topLevel) {
return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`]
return [
`${JSON.stringify(group.identifier)}: adaptGroup${groupTypeName(group)}(raw[${JSON.stringify(group.identifier)}])`,
]
}
const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.identifier)}: raw[${JSON.stringify(item.endpoint.identifier)}]`).join(", ")} }`
return [`...adaptGroup${index}(${raw})`]
return [`...adaptGroup${groupTypeName(group)}(${raw})`]
})
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n`
}
+79 -26
View File
@@ -120,8 +120,8 @@ describe("HttpApiCodegen.generate", () => {
const source = output.files[0]?.content
expect(source).toContain('import type { Session } from "@example/schema/session"')
expect(source).toContain('export type Endpoint0_0Input = { readonly "id": string }')
expect(source).toContain("export type Endpoint0_0Output = Session.Info")
expect(source).toContain('export type SessionGetInput = { readonly "id": string }')
expect(source).toContain("export type SessionGetOutput = Session.Info")
expect(source).not.toContain("HttpApiClient")
expect(source).not.toContain("@example/api")
})
@@ -141,7 +141,53 @@ describe("HttpApiCodegen.generate", () => {
const source = output.files[0]?.content
expect(source).toContain('import type { OpenCodeEvent } from "@example/protocol/event"')
expect(source).toContain("export type Endpoint0_0Output = OpenCodeEvent")
expect(source).toContain("export type SessionEventsOutput = OpenCodeEvent")
})
test("rejects authoritative Effect types colliding with generated aliases", () => {
expect(() =>
emitEffectShape(compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))), {
outputTypes: {
"session.get": {
name: "SessionGetOutput",
import: 'import type { SessionGetOutput } from "@example/schema/session"',
},
},
}),
).toThrow("Generated Effect type collides with imported type: SessionGetOutput")
})
test("rejects qualified Effect imports colliding with generated interfaces", () => {
const Info = Schema.Struct({ id: Schema.String }).annotate({ identifier: "Session.Info" })
expect(() =>
emitEffectShape(compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Info }))), {
typeReferences: [
{
schema: Info,
name: "SessionApi.Info",
import: 'import type { SessionApi } from "@example/schema/session"',
},
],
}),
).toThrow("Generated Effect type collides with imported type: SessionApi")
})
test("rejects imported endpoints colliding with generated adapter values", () => {
const contract = compileContract(api(HttpApiEndpoint.get("session.get", "/session", { success: Schema.String })))
expect(() =>
emitEffectImported(contract, {
module: "@example/api",
endpoints: { "session.session.get": "EndpointSessionGet" },
}),
).toThrow("Generated Effect adapter collides with imported endpoint: EndpointSessionGet")
expect(() =>
emitEffectImported(contract, {
module: "@example/api",
api: "EndpointSessionGet",
}),
).toThrow("Generated Effect adapter collides with imported endpoint: EndpointSessionGet")
})
test("exposes an imported Effect client through its generated shape", () => {
@@ -151,8 +197,8 @@ describe("HttpApiCodegen.generate", () => {
)
const source = output.files.find((file) => file.path === "client.ts")?.content
expect(source).toContain('import type { Endpoint0_0Output } from "../api"')
expect(source).toContain("preserveEffect<Endpoint0_0Output>()")
expect(source).toContain('import type { SessionGetOutput } from "../api"')
expect(source).toContain("preserveEffect<SessionGetOutput>()")
})
test("projects imported endpoint constants into a generated API", () => {
@@ -271,12 +317,12 @@ describe("HttpApiCodegen.generate", () => {
const effect = emitEffect(contract)
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
'"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }',
'"instructions": { "list": EndpointInstructionsList(raw), "put": EndpointInstructionsPut(raw), "remove": EndpointInstructionsRemove(raw) }',
)
const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain(
'"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }',
'"instructions": { "list": EndpointSessionInstructionsList(raw), "put": EndpointSessionInstructionsPut(raw), "remove": EndpointSessionInstructionsRemove(raw) }',
)
const shape = emitEffectShape(contract)
@@ -396,9 +442,14 @@ describe("HttpApiCodegen.generate", () => {
})
test("rejects normalized group, operation-key, and group prototype collisions", () => {
const normalized = HttpApi.make("test")
const sanitized = HttpApi.make("test")
.add(HttpApiGroup.make("foo-bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
expect(() => compileContract(sanitized)).toThrow("Client module name collision: foo-bar")
const normalized = HttpApi.make("test")
.add(HttpApiGroup.make("foo_bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar")
const endpointType = HttpApi.make("test")
@@ -499,7 +550,9 @@ describe("HttpApiCodegen.generate", () => {
expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
expect(effect).toContain(
'const adaptGroupSession = (raw: RawClient["session"]) => ({ "get": EndpointSessionGet(raw) })',
)
expect(effect).toContain('raw["session.get"]')
})
@@ -1478,7 +1531,7 @@ describe("HttpApiCodegen.generate", () => {
expect(output.operations[0]).toBeDefined()
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'extends Schema.TaggedError<Endpoint0Error0Class>("Unauthorized")',
'extends Schema.TaggedError<EndpointGetError0Class>("Unauthorized")',
)
})
@@ -1494,7 +1547,7 @@ describe("HttpApiCodegen.generate", () => {
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
'EndpointGetError0Class.annotate({ "httpApiStatus": 404 })',
)
})
@@ -1504,35 +1557,35 @@ describe("HttpApiCodegen.generate", () => {
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
})
test("uses safe unique module paths without changing public group identifiers", () => {
test("uses safe identity-derived module paths without changing public group identifiers", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
.add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["session.ts", "GROUP-0.ts"])
expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
})
test("reserves support module names case-insensitively", () => {
test("prefixes group modules that collide with support or Windows-reserved names", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String })))
.add(HttpApiGroup.make("CON").add(HttpApiEndpoint.get("get", "/con", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-INDEX.ts", "group-CON.ts"])
})
test("keeps searching when a reserved-name fallback is also occupied", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
test("rejects module names colliding after normalization", () => {
expect(() =>
compile(
HttpApi.make("test")
.add(HttpApiGroup.make("my.group").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("my/group").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
),
).toThrow("Client module name collision: my-group")
})
test("rejects collisions in the flattened client namespace", () => {
@@ -1558,7 +1611,7 @@ describe("HttpApiCodegen.generate", () => {
),
)
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof GroupHealth")
})
it.effect("reports compiler failures in the generate Effect", () =>
@@ -1,15 +1,15 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect } from "effect"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { adaptGroup0, Group0 } from "./session"
import { adaptGroup1, Group1 } from "./event"
import { adaptGroup2, Group2 } from "./system"
import { adaptGroupSession, GroupSession } from "./session"
import { adaptGroupEvent, GroupEvent } from "./event"
import { adaptGroupSystem, GroupSystem } from "./system"
const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2)
const Api = HttpApi.make("generated").add(GroupSession).add(GroupEvent).add(GroupSystem)
const adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({
session: adaptGroup0(raw["session"]),
event: adaptGroup1(raw["event"]),
...adaptGroup2({ status: raw["status"] }),
session: adaptGroupSession(raw["session"]),
event: adaptGroupEvent(raw["event"]),
...adaptGroupSystem({ status: raw["status"] }),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -5,35 +5,35 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { ClientError } from "./client-error.js"
const Endpoint0SuccessData = Schema.Struct({ type: Schema.String })
const EndpointSubscribeSuccessData = Schema.Struct({ type: Schema.String })
const Endpoint0SuccessError = Schema.Never
const EndpointSubscribeSuccessError = Schema.Never
export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add(
export const GroupEvent = HttpApiGroup.make("event", { topLevel: false }).add(
HttpApiEndpoint.make("GET")("subscribe", "/event", {
success: HttpApiSchema.StreamSse({
data: Endpoint0SuccessData,
error: Endpoint0SuccessError,
data: EndpointSubscribeSuccessData,
error: EndpointSubscribeSuccessError,
contentType: "text/event-stream",
}).pipe(HttpApiSchema.status(202)),
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group1, never, never>
type RawGroup = HttpApiClient.Client.Group<typeof GroupEvent, never, never>
const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError])
const mapEndpoint0Error = (error: unknown) =>
const EndpointSubscribeDeclaredError = Schema.Union([EndpointSubscribeSuccessError])
const mapEndpointSubscribeError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
: Schema.is(EndpointSubscribeDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () =>
const EndpointSubscribe = (raw: RawGroup) => () =>
Stream.unwrap(
raw["subscribe"]({}).pipe(
Effect.mapError(mapEndpoint0Error),
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))),
Effect.mapError(mapEndpointSubscribeError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpointSubscribeError))),
),
)
export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) })
export const adaptGroupEvent = (raw: RawGroup) => ({ subscribe: EndpointSubscribe(raw) })
@@ -5,137 +5,141 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ClientError } from "./client-error.js"
const Endpoint0Success = Schema.String
const EndpointHealthSuccess = Schema.String
const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
const EndpointListQuery = Schema.Struct({
archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])),
})
const Endpoint1Success = Schema.Array(Schema.String)
const EndpointListSuccess = Schema.Array(Schema.String)
const Endpoint2Params = Schema.Struct({ sessionID: Schema.String })
const EndpointGetParams = Schema.Struct({ sessionID: Schema.String })
const Endpoint2Success = Schema.Struct({ data: Schema.String })
const EndpointGetSuccess = Schema.Struct({ data: Schema.String })
class Endpoint2Error0Class extends Schema.TaggedError<Endpoint2Error0Class>("Missing")("Missing", {
class EndpointGetError0Class extends Schema.TaggedError<EndpointGetError0Class>("Missing")("Missing", {
message: Schema.String,
}) {}
const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 })
const EndpointGetError0 = EndpointGetError0Class.annotate({ httpApiStatus: 404 })
const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
const EndpointInterruptParams = Schema.Struct({ sessionID: Schema.String })
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
const EndpointInterruptSuccess = Schema.Void.annotate({ httpApiStatus: 204 })
const Endpoint4Params = Schema.Struct({ sessionID: Schema.String })
const EndpointConfigureParams = Schema.Struct({ sessionID: Schema.String })
const Endpoint4Query = Schema.Struct({ dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
const EndpointConfigureQuery = Schema.Struct({
dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])),
})
const Endpoint4Headers = Schema.Struct({ traceID: Schema.String })
const EndpointConfigureHeaders = Schema.Struct({ traceID: Schema.String })
const Endpoint4Payload0 = Schema.Union([
const EndpointConfigurePayload0 = Schema.Union([
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
])
const Endpoint4Success = Schema.String
const EndpointConfigureSuccess = Schema.String
export const Group0 = HttpApiGroup.make("session", { topLevel: false })
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
export const GroupSession = HttpApiGroup.make("session", { topLevel: false })
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: EndpointHealthSuccess }))
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: EndpointListQuery, success: EndpointListSuccess }))
.add(
HttpApiEndpoint.make("GET")("get", "/session/:sessionID", {
params: Endpoint2Params,
success: Endpoint2Success,
error: Endpoint2Error0,
params: EndpointGetParams,
success: EndpointGetSuccess,
error: EndpointGetError0,
}),
)
.add(
HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", {
params: Endpoint3Params,
success: Endpoint3Success,
params: EndpointInterruptParams,
success: EndpointInterruptSuccess,
}),
)
.add(
HttpApiEndpoint.make("POST")("configure", "/session/:sessionID/configure", {
params: Endpoint4Params,
query: Endpoint4Query,
headers: Endpoint4Headers,
payload: Endpoint4Payload0,
success: Endpoint4Success,
params: EndpointConfigureParams,
query: EndpointConfigureQuery,
headers: EndpointConfigureHeaders,
payload: EndpointConfigurePayload0,
success: EndpointConfigureSuccess,
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group0, never, never>
type RawGroup = HttpApiClient.Client.Group<typeof GroupSession, never, never>
const Endpoint0DeclaredError = Schema.Never
const mapEndpoint0Error = (error: unknown) =>
const EndpointHealthDeclaredError = Schema.Never
const mapEndpointHealthError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
: Schema.is(EndpointHealthDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error))
const EndpointHealth = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpointHealthError))
type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] }
const Endpoint1DeclaredError = Schema.Never
const mapEndpoint1Error = (error: unknown) =>
type EndpointListInput = { readonly archived?: (typeof EndpointListQuery.Type)["archived"] }
const EndpointListDeclaredError = Schema.Never
const mapEndpointListError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint1DeclaredError)(error)
: Schema.is(EndpointListDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) =>
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error))
const EndpointList = (raw: RawGroup) => (input?: EndpointListInput) =>
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpointListError))
type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] }
const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0])
const mapEndpoint2Error = (error: unknown) =>
type EndpointGetInput = { readonly sessionID: (typeof EndpointGetParams.Type)["sessionID"] }
const EndpointGetDeclaredError = Schema.Union([EndpointGetError0])
const mapEndpointGetError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint2DeclaredError)(error)
: Schema.is(EndpointGetDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) =>
const EndpointGet = (raw: RawGroup) => (input: EndpointGetInput) =>
raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapEndpoint2Error),
Effect.mapError(mapEndpointGetError),
Effect.map((value) => value.data),
)
type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] }
const Endpoint3DeclaredError = Schema.Never
const mapEndpoint3Error = (error: unknown) =>
type EndpointInterruptInput = { readonly sessionID: (typeof EndpointInterruptParams.Type)["sessionID"] }
const EndpointInterruptDeclaredError = Schema.Never
const mapEndpointInterruptError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint3DeclaredError)(error)
: Schema.is(EndpointInterruptDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
const EndpointInterrupt = (raw: RawGroup) => (input: EndpointInterruptInput) =>
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpointInterruptError))
type Endpoint4Request = Parameters<RawGroup["configure"]>[0]
type Endpoint4Input = {
readonly sessionID: (typeof Endpoint4Params.Type)["sessionID"]
readonly dryRun?: (typeof Endpoint4Query.Type)["dryRun"]
readonly traceID: (typeof Endpoint4Headers.Type)["traceID"]
readonly payload: typeof Endpoint4Payload0.Type
type EndpointConfigureRequest = Parameters<RawGroup["configure"]>[0]
type EndpointConfigureInput = {
readonly sessionID: (typeof EndpointConfigureParams.Type)["sessionID"]
readonly dryRun?: (typeof EndpointConfigureQuery.Type)["dryRun"]
readonly traceID: (typeof EndpointConfigureHeaders.Type)["traceID"]
readonly payload: typeof EndpointConfigurePayload0.Type
}
const Endpoint4DeclaredError = Schema.Never
const mapEndpoint4Error = (error: unknown) =>
const EndpointConfigureDeclaredError = Schema.Never
const mapEndpointConfigureError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint4DeclaredError)(error)
: Schema.is(EndpointConfigureDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint4 = (raw: RawGroup) => (input: Endpoint4Input) =>
const EndpointConfigure = (raw: RawGroup) => (input: EndpointConfigureInput) =>
raw["configure"]({
params: { sessionID: input["sessionID"] },
query: { dryRun: input["dryRun"] },
headers: { traceID: input["traceID"] },
payload: input["payload"],
} as Endpoint4Request).pipe(Effect.mapError(mapEndpoint4Error))
} as EndpointConfigureRequest).pipe(Effect.mapError(mapEndpointConfigureError))
export const adaptGroup0 = (raw: RawGroup) => ({
health: Endpoint0(raw),
list: Endpoint1(raw),
get: Endpoint2(raw),
interrupt: Endpoint3(raw),
configure: Endpoint4(raw),
export const adaptGroupSession = (raw: RawGroup) => ({
health: EndpointHealth(raw),
list: EndpointList(raw),
get: EndpointGet(raw),
interrupt: EndpointInterrupt(raw),
configure: EndpointConfigure(raw),
})
@@ -5,21 +5,21 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ClientError } from "./client-error.js"
const Endpoint0Success = Schema.String
const EndpointStatusSuccess = Schema.String
export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }),
export const GroupSystem = HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.make("GET")("status", "/status", { success: EndpointStatusSuccess }),
)
type RawGroup = HttpApiClient.Client<typeof Group2>
type RawGroup = HttpApiClient.Client<typeof GroupSystem>
const Endpoint0DeclaredError = Schema.Never
const mapEndpoint0Error = (error: unknown) =>
const EndpointStatusDeclaredError = Schema.Never
const mapEndpointStatusError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
: Schema.is(EndpointStatusDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error))
const EndpointStatus = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpointStatusError))
export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) })
export const adaptGroupSystem = (raw: RawGroup) => ({ status: EndpointStatus(raw) })
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -2,8 +2,12 @@ import { OpenApi } from "effect/unstable/httpapi"
import { format } from "prettier"
import { fileURLToPath } from "url"
import { ClientApi } from "../src/client.js"
import { stabilizeOpenApi } from "./openapi-stabilize.js"
const document = await format(JSON.stringify(OpenApi.fromApi(ClientApi), null, 2), { parser: "json", printWidth: 120 })
const document = await format(JSON.stringify(stabilizeOpenApi(OpenApi.fromApi(ClientApi)), null, 2), {
parser: "json",
printWidth: 120,
})
const target = fileURLToPath(new URL("../openapi.json", import.meta.url))
if (process.argv.includes("--check")) {
@@ -0,0 +1,73 @@
// Effect gives shared anonymous schemas encounter-order names (`Union_3`). Replace those names
// with a hash of their canonical shape and sort components so unrelated additions stay local.
export function stabilizeOpenApi(source: object) {
const document = source as {
components: { schemas: Record<string, unknown> }
}
const schemas = document.components.schemas
const reference = (name: string) => `#/components/schemas/${name}`
const families = ["Union", "Objects", "Arrays"].filter((name) => `${name}_` in schemas)
const anonymous = new Map(
Object.keys(schemas)
.filter((name) => families.some((family) => new RegExp(`^${family}_\\d*$`).test(name)))
.map((name) => [name, schemas[name]]),
)
const canonical = (node: unknown, seen = new Set<string>()): unknown => {
if (Array.isArray(node)) return node.map((item) => canonical(item, seen))
if (typeof node !== "object" || node === null) return node
const $ref = "$ref" in node && typeof node.$ref === "string" ? node.$ref : undefined
const name = $ref?.startsWith(reference("")) ? $ref.slice(reference("").length) : undefined
if (name !== undefined && anonymous.has(name)) {
if (seen.has(name)) throw new Error(`Recursive anonymous OpenAPI component: ${name}`)
return Object.fromEntries([
["$ref", canonical(anonymous.get(name), new Set([...seen, name]))],
...Object.entries(node)
.filter(([key]) => key !== "$ref")
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, value]) => [key, canonical(value, seen)]),
])
}
return Object.fromEntries(
Object.entries(node)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, value]) => [key, canonical(value, seen)]),
)
}
const renames = new Map(
[...anonymous].map(([name, schema]) => {
const hash = new Bun.CryptoHasher("sha256")
.update(JSON.stringify(canonical(schema)))
.digest("hex")
.slice(0, 12)
return [name, `${name.replace(/_\d*$/, "")}_${hash}`] as const
}),
)
const rewrite = (node: unknown): unknown => {
if (Array.isArray(node)) return node.map(rewrite)
if (typeof node !== "object" || node === null) return node
return Object.fromEntries(
Object.entries(node).map(([key, value]) => {
if (key !== "$ref" || typeof value !== "string" || !value.startsWith(reference(""))) {
return [key, rewrite(value)]
}
const name = value.slice(reference("").length)
return [key, reference(renames.get(name) ?? name)]
}),
)
}
const result = rewrite(document) as typeof document
const stable = new Map<string, unknown>()
for (const [name, schema] of Object.entries(result.components.schemas)) {
const target = renames.get(name) ?? name
const previous = stable.get(target)
if (previous !== undefined && JSON.stringify(canonical(previous)) !== JSON.stringify(canonical(schema))) {
throw new Error(`Content-addressed OpenAPI component collision: ${target}`)
}
stable.set(target, schema)
}
result.components.schemas = Object.fromEntries(
[...stable].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
)
return result
}
@@ -0,0 +1,77 @@
import { expect, test } from "bun:test"
import { stabilizeOpenApi } from "../script/openapi-stabilize.js"
test("content-addresses anonymous components without changing reference siblings", () => {
const result = stabilizeOpenApi({
components: {
schemas: {
Union_: { anyOf: [{ type: "string" }, { type: "null" }] },
Union_2: { type: "number" },
OAuth_2: { type: "string" },
},
},
paths: {
"/test": {
schema: { $ref: "#/components/schemas/Union_", description: "nullable value" },
},
},
}) as {
components: { schemas: Record<string, unknown> }
paths: { "/test": { schema: { $ref: string; description: string } } }
}
expect(result.paths["/test"].schema.description).toBe("nullable value")
expect(result.paths["/test"].schema.$ref).toMatch(/^#\/components\/schemas\/Union_[a-f0-9]{12}$/)
expect(result.components.schemas.OAuth_2).toEqual({ type: "string" })
})
test("keeps anonymous names stable across encounter order and nested ordinals", () => {
const generate = (nested: string, parent: string) =>
stabilizeOpenApi({
components: {
schemas: {
Union_: { type: "boolean" },
Arrays_: { type: "array", items: { type: "boolean" } },
[parent]: { type: "array", items: { $ref: `#/components/schemas/${nested}` } },
[nested]: { type: "string" },
},
},
}) as { components: { schemas: Record<string, unknown> } }
expect(generate("Union_1", "Arrays_2")).toEqual(generate("Union_9", "Arrays_7"))
})
test("merges structurally identical anonymous components", () => {
const result = stabilizeOpenApi({
components: {
schemas: {
Union_: { type: "string" },
Union_2: { type: "string" },
},
},
}) as { components: { schemas: Record<string, unknown> } }
expect(Object.keys(result.components.schemas)).toHaveLength(1)
})
test("includes reference siblings in anonymous component hashes", () => {
const result = stabilizeOpenApi({
components: {
schemas: {
Union_: { type: "string" },
Arrays_: { type: "array", items: { $ref: "#/components/schemas/Union_", description: "first" } },
Arrays_2: { type: "array", items: { $ref: "#/components/schemas/Union_", description: "second" } },
},
},
}) as { components: { schemas: Record<string, unknown> } }
expect(Object.keys(result.components.schemas).filter((name) => name.startsWith("Arrays_"))).toHaveLength(2)
})
test("preserves authored synthetic-looking names without an anonymous family root", () => {
const result = stabilizeOpenApi({
components: { schemas: { Union_2: { type: "string" } } },
}) as { components: { schemas: Record<string, unknown> } }
expect(result.components.schemas.Union_2).toEqual({ type: "string" })
})
@@ -34,6 +34,8 @@ export type Info = Schema.Schema.Type<typeof Info>
export const Status = Event.ephemeral({
type: "session.status",
// The bare SessionStatus identifier belongs to the status union above.
identifier: "SessionStatusUpdated",
schema: {
sessionID: SessionID,
status: Info,
+4904 -4947
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff