mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 14:43:37 -04:00
fix(codegen): stabilize generated contract names (#44000)
This commit is contained in:
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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
|
||||
+4904
-4947
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+4904
-4947
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user