mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 00:59:54 +00:00
feat(sdk): add oagen effect emitter
This commit is contained in:
parent
8851e4de2b
commit
321d257beb
8 changed files with 4281 additions and 952 deletions
|
|
@ -11,9 +11,11 @@
|
|||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./effect": "./src/effect.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./v2": "./src/v2/index.ts",
|
||||
"./v2/client": "./src/v2/client.ts",
|
||||
"./v2/effect": "./src/v2/effect.ts",
|
||||
"./v2/gen/client": "./src/v2/gen/client/index.ts",
|
||||
"./v2/server": "./src/v2/server.ts"
|
||||
},
|
||||
|
|
@ -25,10 +27,12 @@
|
|||
"@tsconfig/node22": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@workos/oagen": "0.21.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:"
|
||||
"cross-spawn": "catalog:",
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
1
packages/sdk/js/src/effect.ts
Normal file
1
packages/sdk/js/src/effect.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./v2/effect.js"
|
||||
|
|
@ -4,7 +4,8 @@ import { createClient } from "./gen/client/client.gen.js"
|
|||
import { type Config } from "./gen/client/types.gen.js"
|
||||
import { OpencodeClient } from "./gen/sdk.gen.js"
|
||||
import { wrapClientError } from "../error-interceptor.js"
|
||||
export { type Config as OpencodeClientConfig, OpencodeClient }
|
||||
export { OpencodeClient }
|
||||
export type OpencodeClientConfig = Config & { directory?: string; experimental_workspaceID?: string }
|
||||
|
||||
function pick(value: string | null, fallback?: string, encode?: (value: string) => string) {
|
||||
if (!value) return
|
||||
|
|
@ -44,7 +45,7 @@ function rewrite(request: Request, values: { directory?: string; workspace?: str
|
|||
return next
|
||||
}
|
||||
|
||||
export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
|
||||
export function createOpencodeFetchClient(config?: OpencodeClientConfig) {
|
||||
if (!config?.fetch) {
|
||||
const customFetch: any = (req: any) => {
|
||||
// @ts-ignore
|
||||
|
|
@ -86,5 +87,9 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
|
|||
return response
|
||||
})
|
||||
client.interceptors.error.use(wrapClientError)
|
||||
return new OpencodeClient({ client })
|
||||
return client
|
||||
}
|
||||
|
||||
export function createOpencodeClient(config?: OpencodeClientConfig) {
|
||||
return new OpencodeClient({ client: createOpencodeFetchClient(config) })
|
||||
}
|
||||
|
|
|
|||
8
packages/sdk/js/src/v2/effect.ts
Normal file
8
packages/sdk/js/src/v2/effect.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export * from "./gen/effect.gen.js"
|
||||
|
||||
import { createOpencodeFetchClient, type OpencodeClientConfig } from "./client.js"
|
||||
import { createOpencodeEffectClient as createGeneratedOpencodeEffectClient } from "./gen/effect.gen.js"
|
||||
|
||||
export function createOpencodeEffectClient(config?: OpencodeClientConfig) {
|
||||
return createGeneratedOpencodeEffectClient(createOpencodeFetchClient(config))
|
||||
}
|
||||
3323
packages/sdk/js/src/v2/gen/effect.gen.ts
Normal file
3323
packages/sdk/js/src/v2/gen/effect.gen.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue