feat(sdk): add oagen effect emitter
This commit is contained in:
parent
8851e4de2b
commit
321d257beb
8 changed files with 4281 additions and 952 deletions
|
|
@ -8,6 +8,8 @@ import { $ } from "bun"
|
|||
import path from "path"
|
||||
|
||||
import { createClient } from "@hey-api/openapi-ts"
|
||||
import { generateFiles, parseSpec } from "@workos/oagen"
|
||||
import { effectEmitter } from "./effect-emitter.js"
|
||||
|
||||
const opencode = path.resolve(dir, "../../opencode")
|
||||
|
||||
|
|
@ -58,8 +60,41 @@ if (sseTypesPatched === sseTypesSource) {
|
|||
}
|
||||
await Bun.write(sseTypesPath, sseTypesPatched)
|
||||
|
||||
const openapi = await Bun.file("./openapi.json").json()
|
||||
const effect = generateFiles(await parseSpec("./openapi.json"), effectEmitter, {
|
||||
namespace: "Opencode",
|
||||
outputDir: "./src/v2/gen",
|
||||
emitterOptions: {
|
||||
serverSentEvents: serverSentEvents(openapi),
|
||||
},
|
||||
})
|
||||
for (const file of effect.files) {
|
||||
await Bun.write(path.join("./src/v2/gen", file.path), file.content)
|
||||
}
|
||||
|
||||
await $`bun prettier --write src/gen`
|
||||
await $`bun prettier --write src/v2`
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc`
|
||||
await $`rm openapi.json`
|
||||
|
||||
function serverSentEvents(spec: unknown) {
|
||||
if (!spec || typeof spec !== "object" || !("paths" in spec) || !spec.paths || typeof spec.paths !== "object")
|
||||
return []
|
||||
|
||||
return Object.entries(spec.paths).flatMap(([route, value]) => {
|
||||
if (!value || typeof value !== "object") return []
|
||||
|
||||
return Object.entries(value).flatMap(([method, operation]) => {
|
||||
if (!operation || typeof operation !== "object" || !("responses" in operation)) return []
|
||||
if (!hasEventStream(operation.responses)) return []
|
||||
return [`${method.toUpperCase()} ${route}`]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function hasEventStream(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if ("text/event-stream" in value) return true
|
||||
return Object.values(value).some(hasEventStream)
|
||||
}
|
||||
|
|
|
|||
209
packages/sdk/js/script/effect-emitter.ts
Normal file
209
packages/sdk/js/script/effect-emitter.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import type { ApiSpec, Emitter, EmitterContext, GeneratedFile, Operation, Service } from "@workos/oagen"
|
||||
|
||||
type EffectEmitterOptions = {
|
||||
serverSentEvents?: string[]
|
||||
}
|
||||
|
||||
export const effectEmitter: Emitter = {
|
||||
language: "effect",
|
||||
|
||||
generateModels(): GeneratedFile[] {
|
||||
return []
|
||||
},
|
||||
|
||||
generateEnums(): GeneratedFile[] {
|
||||
return []
|
||||
},
|
||||
|
||||
generateResources(): GeneratedFile[] {
|
||||
return []
|
||||
},
|
||||
|
||||
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
||||
return [generateEffectClient(spec, ctx)]
|
||||
},
|
||||
|
||||
generateErrors(): GeneratedFile[] {
|
||||
return []
|
||||
},
|
||||
|
||||
generateTests(): GeneratedFile[] {
|
||||
return []
|
||||
},
|
||||
|
||||
fileHeader() {
|
||||
return "// This file is auto-generated by oagen. Do not edit."
|
||||
},
|
||||
}
|
||||
|
||||
function generateEffectClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
||||
const sse = new Set((ctx.emitterOptions as EffectEmitterOptions | undefined)?.serverSentEvents ?? [])
|
||||
const operations = spec.services.flatMap((service) => service.operations.map((operation) => ({ service, operation })))
|
||||
const typeImports = operations.flatMap(({ operation }) => {
|
||||
const type = operationType(operation)
|
||||
if (operation.errors.length === 0) return [`${type}Data`, `${type}Response`, `${type}Responses`]
|
||||
return [`${type}Data`, `${type}Error`, `${type}Errors`, `${type}Response`, `${type}Responses`]
|
||||
})
|
||||
|
||||
return {
|
||||
path: "effect.gen.ts",
|
||||
content: [
|
||||
`import { Effect } from "effect"`,
|
||||
`import type { Client, Options, TDataShape } from "./client/index.js"`,
|
||||
`import type { ServerSentEventsResult } from "./core/serverSentEvents.gen.js"`,
|
||||
`import type {`,
|
||||
...Array.from(new Set(typeImports))
|
||||
.sort()
|
||||
.map((name) => ` ${name},`),
|
||||
`} from "./types.gen.js"`,
|
||||
``,
|
||||
`export type OpencodeEffectOptions<TData extends TDataShape, TResponse> = Omit<`,
|
||||
` Options<TData, true, TResponse, "data">,`,
|
||||
` "responseStyle" | "throwOnError"`,
|
||||
`>`,
|
||||
``,
|
||||
`export interface ${ctx.namespacePascal}EffectClient {`,
|
||||
...spec.services.flatMap((service) => serviceShape(service, sse)),
|
||||
`}`,
|
||||
``,
|
||||
`export function create${ctx.namespacePascal}EffectClient(client: Client): ${ctx.namespacePascal}EffectClient {`,
|
||||
` return {`,
|
||||
...spec.services.flatMap((service) => serviceFactory(service, sse)),
|
||||
` }`,
|
||||
`}`,
|
||||
``,
|
||||
`function request<T, E>(evaluate: () => Promise<T>) {`,
|
||||
` return Effect.tryPromise({`,
|
||||
` try: evaluate,`,
|
||||
` catch: (error) => error as E | Error,`,
|
||||
` })`,
|
||||
`}`,
|
||||
].join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
function serviceShape(service: Service, sse: Set<string>) {
|
||||
return [
|
||||
` ${propertyName(service.name)}: {`,
|
||||
...service.operations.flatMap((operation) => [doc(operation, " "), ` ${methodSignature(operation, sse)}`]),
|
||||
` }`,
|
||||
]
|
||||
}
|
||||
|
||||
function serviceFactory(service: Service, sse: Set<string>) {
|
||||
return [
|
||||
` ${propertyName(service.name)}: {`,
|
||||
...service.operations.map(
|
||||
(operation) => ` ${propertyName(operation.name)}: ${methodFactory(operation, sse)},`,
|
||||
),
|
||||
` },`,
|
||||
]
|
||||
}
|
||||
|
||||
function methodSignature(operation: Operation, sse: Set<string>) {
|
||||
const optional = hasRequiredOptions(operation) ? "" : "?"
|
||||
return `${propertyName(operation.name)}(options${optional}: OpencodeEffectOptions<${operationType(
|
||||
operation,
|
||||
)}Data, ${operationType(operation)}Responses>): Effect.Effect<${operationResponse(operation, sse)}, ${operationError(
|
||||
operation,
|
||||
)}>`
|
||||
}
|
||||
|
||||
function methodFactory(operation: Operation, sse: Set<string>) {
|
||||
const type = operationType(operation)
|
||||
const args = [
|
||||
`url: ${JSON.stringify(operation.path)}`,
|
||||
`...options`,
|
||||
`throwOnError: true`,
|
||||
`responseStyle: "data"`,
|
||||
contentType(operation),
|
||||
].filter((line): line is string => Boolean(line))
|
||||
const request = isSse(operation, sse)
|
||||
? `client.sse.${operation.httpMethod}<${type}Responses, ${operationErrorTypes(operation)}, true, "data">({ ${args.join(
|
||||
", ",
|
||||
)} })`
|
||||
: `client.${operation.httpMethod}<${type}Responses, ${operationErrorTypes(operation)}, true, "data">({ ${args.join(
|
||||
", ",
|
||||
)} })`
|
||||
return `(options${hasRequiredOptions(operation) ? "" : "?"}) => request<${operationResponse(
|
||||
operation,
|
||||
sse,
|
||||
)}, ${operationErrorValue(operation)}>(() => ${request})`
|
||||
}
|
||||
|
||||
function operationResponse(operation: Operation, sse: Set<string>) {
|
||||
const type = operationType(operation)
|
||||
if (isSse(operation, sse)) return `ServerSentEventsResult<${type}Responses>`
|
||||
return `${type}Response`
|
||||
}
|
||||
|
||||
function operationError(operation: Operation) {
|
||||
if (operation.errors.length === 0) return "Error"
|
||||
return `${operationType(operation)}Error | Error`
|
||||
}
|
||||
|
||||
function operationErrorValue(operation: Operation) {
|
||||
if (operation.errors.length === 0) return "never"
|
||||
return `${operationType(operation)}Error`
|
||||
}
|
||||
|
||||
function operationErrorTypes(operation: Operation) {
|
||||
if (operation.errors.length === 0) return "unknown"
|
||||
return `${operationType(operation)}Errors`
|
||||
}
|
||||
|
||||
function contentType(operation: Operation) {
|
||||
if (!operation.requestBody) return
|
||||
const value = {
|
||||
binary: "application/octet-stream",
|
||||
json: "application/json",
|
||||
text: "text/plain",
|
||||
"form-urlencoded": "application/x-www-form-urlencoded",
|
||||
"form-data": undefined,
|
||||
}[operation.requestBodyEncoding ?? "json"]
|
||||
if (!value) return
|
||||
return `headers: { "Content-Type": ${JSON.stringify(value)}, ...options?.headers }`
|
||||
}
|
||||
|
||||
function hasRequiredOptions(operation: Operation) {
|
||||
return (
|
||||
operation.pathParams.length > 0 ||
|
||||
operation.queryParams.some((item) => item.required) ||
|
||||
operation.headerParams.some((item) => item.required)
|
||||
)
|
||||
}
|
||||
|
||||
function isSse(operation: Operation, sse: Set<string>) {
|
||||
return sse.has(`${operation.httpMethod.toUpperCase()} ${operation.path}`)
|
||||
}
|
||||
|
||||
function operationType(operation: Operation) {
|
||||
return identifier(operation.name)
|
||||
}
|
||||
|
||||
function propertyName(value: string) {
|
||||
return value.charAt(0).toLowerCase() + value.slice(1)
|
||||
}
|
||||
|
||||
function identifier(value: string) {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[^A-Za-z0-9]+/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join("")
|
||||
}
|
||||
|
||||
function doc(operation: Operation, indent: string) {
|
||||
if (!operation.description) return `${indent}/** ${operation.httpMethod.toUpperCase()} ${operation.path} */`
|
||||
return [
|
||||
`${indent}/**`,
|
||||
...operation.description
|
||||
.replaceAll("*/", "* /")
|
||||
.split("\n")
|
||||
.map((line) => `${indent} * ${line}`),
|
||||
`${indent} */`,
|
||||
].join("\n")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue