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": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
|
"./effect": "./src/effect.ts",
|
||||||
"./server": "./src/server.ts",
|
"./server": "./src/server.ts",
|
||||||
"./v2": "./src/v2/index.ts",
|
"./v2": "./src/v2/index.ts",
|
||||||
"./v2/client": "./src/v2/client.ts",
|
"./v2/client": "./src/v2/client.ts",
|
||||||
|
"./v2/effect": "./src/v2/effect.ts",
|
||||||
"./v2/gen/client": "./src/v2/gen/client/index.ts",
|
"./v2/gen/client": "./src/v2/gen/client/index.ts",
|
||||||
"./v2/server": "./src/v2/server.ts"
|
"./v2/server": "./src/v2/server.ts"
|
||||||
},
|
},
|
||||||
|
|
@ -25,10 +27,12 @@
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/cross-spawn": "catalog:",
|
"@types/cross-spawn": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@workos/oagen": "0.21.1",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:"
|
"cross-spawn": "catalog:",
|
||||||
|
"effect": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { $ } from "bun"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
import { createClient } from "@hey-api/openapi-ts"
|
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")
|
const opencode = path.resolve(dir, "../../opencode")
|
||||||
|
|
||||||
|
|
@ -58,8 +60,41 @@ if (sseTypesPatched === sseTypesSource) {
|
||||||
}
|
}
|
||||||
await Bun.write(sseTypesPath, sseTypesPatched)
|
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/gen`
|
||||||
await $`bun prettier --write src/v2`
|
await $`bun prettier --write src/v2`
|
||||||
await $`rm -rf dist`
|
await $`rm -rf dist`
|
||||||
await $`bun tsc`
|
await $`bun tsc`
|
||||||
await $`rm openapi.json`
|
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 { type Config } from "./gen/client/types.gen.js"
|
||||||
import { OpencodeClient } from "./gen/sdk.gen.js"
|
import { OpencodeClient } from "./gen/sdk.gen.js"
|
||||||
import { wrapClientError } from "../error-interceptor.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) {
|
function pick(value: string | null, fallback?: string, encode?: (value: string) => string) {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
@ -44,7 +45,7 @@ function rewrite(request: Request, values: { directory?: string; workspace?: str
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
|
export function createOpencodeFetchClient(config?: OpencodeClientConfig) {
|
||||||
if (!config?.fetch) {
|
if (!config?.fetch) {
|
||||||
const customFetch: any = (req: any) => {
|
const customFetch: any = (req: any) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
@ -86,5 +87,9 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
|
||||||
return response
|
return response
|
||||||
})
|
})
|
||||||
client.interceptors.error.use(wrapClientError)
|
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