From bdbf2d4102e7185f9c493b4ff10bebdbf0682b33 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 3 Jun 2026 14:34:44 +0000 Subject: [PATCH] chore(sdk): add generated effect example --- .../js/example/effect-sdk/gen/client.gen.ts | 18 ++ .../effect-sdk/gen/client/client.gen.ts | 285 +++++++++++++++++ .../js/example/effect-sdk/gen/client/index.ts | 25 ++ .../effect-sdk/gen/client/types.gen.ts | 202 ++++++++++++ .../effect-sdk/gen/client/utils.gen.ts | 289 ++++++++++++++++++ .../example/effect-sdk/gen/core/auth.gen.ts | 41 +++ .../effect-sdk/gen/core/bodySerializer.gen.ts | 82 +++++ .../example/effect-sdk/gen/core/params.gen.ts | 169 ++++++++++ .../effect-sdk/gen/core/pathSerializer.gen.ts | 167 ++++++++++ .../gen/core/queryKeySerializer.gen.ts | 111 +++++++ .../gen/core/serverSentEvents.gen.ts | 239 +++++++++++++++ .../example/effect-sdk/gen/core/types.gen.ts | 86 ++++++ .../example/effect-sdk/gen/core/utils.gen.ts | 137 +++++++++ .../js/example/effect-sdk/gen/effect.gen.ts | 94 ++++++ .../js/example/effect-sdk/gen/types.gen.ts | 93 ++++++ .../sdk/js/example/effect-sdk/openapi.json | 174 +++++++++++ packages/sdk/js/script/build.ts | 71 +++-- packages/sdk/js/script/effect-emitter.ts | 15 +- 18 files changed, 2275 insertions(+), 23 deletions(-) create mode 100644 packages/sdk/js/example/effect-sdk/gen/client.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/client/client.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/client/index.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/client/types.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/client/utils.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/core/auth.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/core/bodySerializer.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/core/params.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/core/pathSerializer.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/core/queryKeySerializer.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/core/serverSentEvents.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/core/types.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/core/utils.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/effect.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/gen/types.gen.ts create mode 100644 packages/sdk/js/example/effect-sdk/openapi.json diff --git a/packages/sdk/js/example/effect-sdk/gen/client.gen.ts b/packages/sdk/js/example/effect-sdk/gen/client.gen.ts new file mode 100644 index 00000000000..e7600afef1e --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/client.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from "./client/index.js" +import type { ClientOptions as ClientOptions2 } from "./types.gen.js" + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T> + +export const client = createClient(createConfig({ baseUrl: "https://api.example.com" })) diff --git a/packages/sdk/js/example/effect-sdk/gen/client/client.gen.ts b/packages/sdk/js/example/effect-sdk/gen/client/client.gen.ts new file mode 100644 index 00000000000..627e98ec420 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/client/client.gen.ts @@ -0,0 +1,285 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from "../core/serverSentEvents.gen.js" +import type { HttpMethod } from "../core/types.gen.js" +import { getValidRequestBody } from "../core/utils.gen.js" +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js" +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from "./utils.gen.js" + +type ReqInit = Omit & { + body?: any + headers: ReturnType +} + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config) + + const getConfig = (): Config => ({ ..._config }) + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config) + return getConfig() + } + + const interceptors = createInterceptors() + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + } + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }) + } + + if (opts.requestValidator) { + await opts.requestValidator(opts) + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === "") { + opts.headers.delete("Content-Type") + } + + const url = buildUrl(opts) + + return { opts, url } + } + + const request: Client["request"] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options) + const requestInit: ReqInit = { + redirect: "follow", + ...opts, + body: getValidRequestBody(opts), + } + + let request = new Request(url, requestInit) + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts) + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch! + let response: Response + + try { + response = await _fetch(request) + } catch (error) { + // Handle fetch exceptions (AbortError, network errors, etc.) + let finalError = error + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, undefined as any, request, opts)) as unknown + } + } + + finalError = finalError || ({} as unknown) + + if (opts.throwOnError) { + throw finalError + } + + // Return error response + return opts.responseStyle === "data" + ? undefined + : { + error: finalError, + request, + response: undefined as any, + } + } + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts) + } + } + + const result = { + request, + response, + } + + if (response.ok) { + const parseAs = + (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json" + + if (response.status === 204 || response.headers.get("Content-Length") === "0") { + let emptyData: any + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "text": + emptyData = await response[parseAs]() + break + case "formData": + emptyData = new FormData() + break + case "stream": + emptyData = response.body + break + case "json": + default: + emptyData = {} + break + } + return opts.responseStyle === "data" + ? emptyData + : { + data: emptyData, + ...result, + } + } + + let data: any + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "formData": + case "text": + data = await response[parseAs]() + break + case "json": { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text() + data = text ? JSON.parse(text) : {} + break + } + case "stream": + return opts.responseStyle === "data" + ? response.body + : { + data: response.body, + ...result, + } + } + + if (parseAs === "json") { + if (opts.responseValidator) { + await opts.responseValidator(data) + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data) + } + } + + return opts.responseStyle === "data" + ? data + : { + data, + ...result, + } + } + + const textError = await response.text() + let jsonError: unknown + + try { + jsonError = JSON.parse(textError) + } catch { + // noop + } + + const error = jsonError ?? textError + let finalError = error + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string + } + } + + finalError = finalError || ({} as string) + + if (opts.throwOnError) { + throw finalError + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === "data" + ? undefined + : { + error: finalError, + ...result, + } + } + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => request({ ...options, method }) + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options) + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + onRequest: async (url, init) => { + let request = new Request(url, init) + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts) + } + } + return request + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }) + } + + return { + buildUrl, + connect: makeMethodFn("CONNECT"), + delete: makeMethodFn("DELETE"), + get: makeMethodFn("GET"), + getConfig, + head: makeMethodFn("HEAD"), + interceptors, + options: makeMethodFn("OPTIONS"), + patch: makeMethodFn("PATCH"), + post: makeMethodFn("POST"), + put: makeMethodFn("PUT"), + request, + setConfig, + sse: { + connect: makeSseFn("CONNECT"), + delete: makeSseFn("DELETE"), + get: makeSseFn("GET"), + head: makeSseFn("HEAD"), + options: makeSseFn("OPTIONS"), + patch: makeSseFn("PATCH"), + post: makeSseFn("POST"), + put: makeSseFn("PUT"), + trace: makeSseFn("TRACE"), + }, + trace: makeMethodFn("TRACE"), + } as Client +} diff --git a/packages/sdk/js/example/effect-sdk/gen/client/index.ts b/packages/sdk/js/example/effect-sdk/gen/client/index.ts new file mode 100644 index 00000000000..0af63f3300e --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from "../core/auth.gen.js" +export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from "../core/bodySerializer.gen.js" +export { buildClientParams } from "../core/params.gen.js" +export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen.js" +export { createClient } from "./client.gen.js" +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from "./types.gen.js" +export { createConfig, mergeHeaders } from "./utils.gen.js" diff --git a/packages/sdk/js/example/effect-sdk/gen/client/types.gen.ts b/packages/sdk/js/example/effect-sdk/gen/client/types.gen.ts new file mode 100644 index 00000000000..99d7e7f8f2e --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/client/types.gen.ts @@ -0,0 +1,202 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from "../core/auth.gen.js" +import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen.js" +import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js" +import type { Middleware } from "./utils.gen.js" + +export type ResponseStyle = "data" | "fields" + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T["baseUrl"] + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text" + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T["throwOnError"] +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle + throwOnError: ThrowOnError + }>, + Pick< + ServerSentEventsOptions, + "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay" + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown + path?: Record + query?: Record + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray + url: Url +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends "data" + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData + request: Request + response: Response + } + > + : Promise< + TResponseStyle extends "data" + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData + error: undefined + } + | { + data: undefined + error: TError extends Record ? TError[keyof TError] : TError + } + ) & { + request: Request + response: Response + } + > + +export interface ClientOptions { + baseUrl?: string + responseStyle?: ResponseStyle + throwOnError?: boolean +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => RequestResult + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => Promise> + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method"> & + Pick>, "method">, +) => RequestResult + +type BuildUrlFn = < + TData extends { + body?: unknown + path?: Record + query?: Record + url: string + }, +>( + options: TData & Options, +) => string + +export type Client = CoreClient & { + interceptors: Middleware +} + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T> + +export interface TDataShape { + body?: unknown + headers?: unknown + path?: unknown + query?: unknown + url: string +} + +type OmitKeys = Pick> + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = "fields", +> = OmitKeys, "body" | "path" | "query" | "url"> & + ([TData] extends [never] ? unknown : Omit) diff --git a/packages/sdk/js/example/effect-sdk/gen/client/utils.gen.ts b/packages/sdk/js/example/effect-sdk/gen/client/utils.gen.ts new file mode 100644 index 00000000000..3b1dfb78718 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/client/utils.gen.ts @@ -0,0 +1,289 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from "../core/auth.gen.js" +import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" +import { jsonBodySerializer } from "../core/bodySerializer.gen.js" +import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen.js" +import { getUrl } from "../core/utils.gen.js" +import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js" + +export const createQuerySerializer = ({ parameters = {}, ...args }: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = [] + if (queryParams && typeof queryParams === "object") { + for (const name in queryParams) { + const value = queryParams[name] + + if (value === undefined || value === null) { + continue + } + + const options = parameters[name] || args + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: "form", + value, + ...options.array, + }) + if (serializedArray) search.push(serializedArray) + } else if (typeof value === "object") { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: "deepObject", + value: value as Record, + ...options.object, + }) + if (serializedObject) search.push(serializedObject) + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }) + if (serializedPrimitive) search.push(serializedPrimitive) + } + } + } + return search.join("&") + } + return querySerializer +} + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return "stream" + } + + const cleanContent = contentType.split(";")[0]?.trim() + + if (!cleanContent) { + return + } + + if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) { + return "json" + } + + if (cleanContent === "multipart/form-data") { + return "formData" + } + + if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) { + return "blob" + } + + if (cleanContent.startsWith("text/")) { + return "text" + } + + return +} + +const checkForExistence = ( + options: Pick & { + headers: Headers + }, + name?: string, +): boolean => { + if (!name) { + return false + } + if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) { + return true + } + return false +} + +export const setAuthParams = async ({ + security, + ...options +}: Pick, "security"> & + Pick & { + headers: Headers + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue + } + + const token = await getAuthToken(auth, options.auth) + + if (!token) { + continue + } + + const name = auth.name ?? "Authorization" + + switch (auth.in) { + case "query": + if (!options.query) { + options.query = {} + } + options.query[name] = token + break + case "cookie": + options.headers.append("Cookie", `${name}=${token}`) + break + case "header": + default: + options.headers.set(name, token) + break + } + } +} + +export const buildUrl: Client["buildUrl"] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === "function" + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }) + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b } + if (config.baseUrl?.endsWith("/")) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1) + } + config.headers = mergeHeaders(a.headers, b.headers) + return config +} + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = [] + headers.forEach((value, key) => { + entries.push([key, value]) + }) + return entries +} + +export const mergeHeaders = (...headers: Array["headers"] | undefined>): Headers => { + const mergedHeaders = new Headers() + for (const header of headers) { + if (!header) { + continue + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header) + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key) + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string) + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string)) + } + } + } + return mergedHeaders +} + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise + +type ResInterceptor = (response: Res, request: Req, options: Options) => Res | Promise + +class Interceptors { + fns: Array = [] + + clear(): void { + this.fns = [] + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id) + if (this.fns[index]) { + this.fns[index] = null + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id) + return Boolean(this.fns[index]) + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === "number") { + return this.fns[id] ? id : -1 + } + return this.fns.indexOf(id) + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id) + if (this.fns[index]) { + this.fns[index] = fn + return id + } + return false + } + + use(fn: Interceptor): number { + this.fns.push(fn) + return this.fns.length - 1 + } +} + +export interface Middleware { + error: Interceptors> + request: Interceptors> + response: Interceptors> +} + +export const createInterceptors = (): Middleware => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}) + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: "form", + }, + object: { + explode: true, + style: "deepObject", + }, +}) + +const defaultHeaders = { + "Content-Type": "application/json", +} + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: "auto", + querySerializer: defaultQuerySerializer, + ...override, +}) diff --git a/packages/sdk/js/example/effect-sdk/gen/core/auth.gen.ts b/packages/sdk/js/example/effect-sdk/gen/core/auth.gen.ts new file mode 100644 index 00000000000..bc7b230f447 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/core/auth.gen.ts @@ -0,0 +1,41 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: "header" | "query" | "cookie" + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string + scheme?: "basic" | "bearer" + type: "apiKey" | "http" +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === "function" ? await callback(auth) : callback + + if (!token) { + return + } + + if (auth.scheme === "bearer") { + return `Bearer ${token}` + } + + if (auth.scheme === "basic") { + return `Basic ${btoa(token)}` + } + + return token +} diff --git a/packages/sdk/js/example/effect-sdk/gen/core/bodySerializer.gen.ts b/packages/sdk/js/example/effect-sdk/gen/core/bodySerializer.gen.ts new file mode 100644 index 00000000000..9678fb08ec6 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js" + +export type QuerySerializer = (query: Record) => string + +export type BodySerializer = (body: any) => any + +type QuerySerializerOptionsObject = { + allowReserved?: boolean + array?: Partial> + object?: Partial> +} + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record +} + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === "string" || value instanceof Blob) { + data.append(key, value) + } else if (value instanceof Date) { + data.append(key, value.toISOString()) + } else { + data.append(key, JSON.stringify(value)) + } +} + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === "string") { + data.append(key, value) + } else { + data.append(key, JSON.stringify(value)) + } +} + +export const formDataBodySerializer = { + bodySerializer: | Array>>(body: T): FormData => { + const data = new FormData() + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)) + } else { + serializeFormDataPair(data, key, value) + } + }) + + return data + }, +} + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), +} + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>(body: T): string => { + const data = new URLSearchParams() + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) + } else { + serializeUrlSearchParamsPair(data, key, value) + } + }) + + return data.toString() + }, +} diff --git a/packages/sdk/js/example/effect-sdk/gen/core/params.gen.ts b/packages/sdk/js/example/effect-sdk/gen/core/params.gen.ts new file mode 100644 index 00000000000..6e9d0b9add4 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/core/params.gen.ts @@ -0,0 +1,169 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = "body" | "headers" | "path" | "query" + +export type Field = + | { + in: Exclude + /** + * Field name. This is the name we want the user to see and use. + */ + key: string + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string + } + | { + in: Extract + /** + * Key isn't required for bodies. + */ + key?: string + map?: string + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot + } + +export interface Fields { + allowExtra?: Partial> + args?: ReadonlyArray +} + +export type FieldsConfig = ReadonlyArray + +const extraPrefixesMap: Record = { + $body_: "body", + $headers_: "headers", + $path_: "path", + $query_: "query", +} +const extraPrefixes = Object.entries(extraPrefixesMap) + +type KeyMap = Map< + string, + | { + in: Slot + map?: string + } + | { + in?: never + map: Slot + } +> + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map() + } + + for (const config of fields) { + if ("in" in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }) + } + } else if ("key" in config) { + map.set(config.key, { + map: config.map, + }) + } else if (config.args) { + buildKeyMap(config.args, map) + } + } + + return map +} + +interface Params { + body: unknown + headers: Record + path: Record + query: Record +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === "object" && !Object.keys(value).length) { + delete params[slot as Slot] + } + } +} + +export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + } + + const map = buildKeyMap(fields) + + let config: FieldsConfig[number] | undefined + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index] + } + + if (!config) { + continue + } + + if ("in" in config) { + if (config.key) { + const field = map.get(config.key)! + const name = field.map || config.key + if (field.in) { + ;(params[field.in] as Record)[name] = arg + } + } else { + params.body = arg + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key) + + if (field) { + if (field.in) { + const name = field.map || key + ;(params[field.in] as Record)[name] = value + } else { + params[field.map] = value + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) + + if (extra) { + const [prefix, slot] = extra + ;(params[slot] as Record)[key.slice(prefix.length)] = value + } else if ("allowExtra" in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + ;(params[slot as Slot] as Record)[key] = value + break + } + } + } + } + } + } + } + + stripEmptySlots(params) + + return params +} diff --git a/packages/sdk/js/example/effect-sdk/gen/core/pathSerializer.gen.ts b/packages/sdk/js/example/effect-sdk/gen/core/pathSerializer.gen.ts new file mode 100644 index 00000000000..96be3bc5a39 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/core/pathSerializer.gen.ts @@ -0,0 +1,167 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean + name: string +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean + style: T +} + +export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited" +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle +type MatrixStyle = "label" | "matrix" | "simple" +export type ObjectStyle = "form" | "deepObject" +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "label": + return "." + case "matrix": + return ";" + case "simple": + return "," + default: + return "&" + } +} + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "form": + return "," + case "pipeDelimited": + return "|" + case "spaceDelimited": + return "%20" + default: + return "," + } +} + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case "label": + return "." + case "matrix": + return ";" + case "simple": + return "," + default: + return "&" + } +} + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[] +}) => { + if (!explode) { + const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( + separatorArrayNoExplode(style), + ) + switch (style) { + case "label": + return `.${joinedValues}` + case "matrix": + return `;${name}=${joinedValues}` + case "simple": + return joinedValues + default: + return `${name}=${joinedValues}` + } + } + + const separator = separatorArrayExplode(style) + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string) + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }) + }) + .join(separator) + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues +} + +export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return "" + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ) + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}` +} + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date + valueOnly?: boolean +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` + } + + if (style !== "deepObject" && !explode) { + let values: string[] = [] + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)] + }) + const joinedValues = values.join(",") + switch (style) { + case "form": + return `${name}=${joinedValues}` + case "label": + return `.${joinedValues}` + case "matrix": + return `;${name}=${joinedValues}` + default: + return joinedValues + } + } + + const separator = separatorObjectExplode(style) + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator) + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues +} diff --git a/packages/sdk/js/example/effect-sdk/gen/core/queryKeySerializer.gen.ts b/packages/sdk/js/example/effect-sdk/gen/core/queryKeySerializer.gen.ts new file mode 100644 index 00000000000..320204aef10 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/core/queryKeySerializer.gen.ts @@ -0,0 +1,111 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue } + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined + } + if (typeof value === "bigint") { + return value.toString() + } + if (value instanceof Date) { + return value.toISOString() + } + return value +} + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer) + if (json === undefined) { + return undefined + } + return JSON.parse(json) as JsonValue + } catch { + return undefined + } +} + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false + } + const prototype = Object.getPrototypeOf(value as object) + return prototype === Object.prototype || prototype === null +} + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)) + const result: Record = {} + + for (const [key, value] of entries) { + const existing = result[key] + if (existing === undefined) { + result[key] = value + continue + } + + if (Array.isArray(existing)) { + ;(existing as string[]).push(value) + } else { + result[key] = [existing, value] + } + } + + return result +} + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value + } + + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined + } + + if (typeof value === "bigint") { + return value.toString() + } + + if (value instanceof Date) { + return value.toISOString() + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value) + } + + if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) { + return serializeSearchParams(value) + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value) + } + + return undefined +} diff --git a/packages/sdk/js/example/effect-sdk/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/example/effect-sdk/gen/core/serverSentEvents.gen.ts new file mode 100644 index 00000000000..056a8125932 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/core/serverSentEvents.gen.ts @@ -0,0 +1,239 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from "./types.gen.js" + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void + serializedBody?: RequestInit["body"] + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise + url: string + } + +export interface StreamEvent { + data: TData + event?: string + id?: string + retry?: number +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator ? TData[keyof TData] : TData, TReturn, TNext> +} + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000 + let attempt = 0 + const signal = options.signal ?? new AbortController().signal + + while (true) { + if (signal.aborted) break + + attempt++ + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined) + + if (lastEventId !== undefined) { + headers.set("Last-Event-ID", lastEventId) + } + + try { + const requestInit: RequestInit = { + redirect: "follow", + ...options, + body: options.serializedBody, + headers, + signal, + } + let request = new Request(url, requestInit) + if (onRequest) { + request = await onRequest(url, requestInit) + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch + const response = await _fetch(request) + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`) + + if (!response.body) throw new Error("No body in SSE response") + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + + let buffer = "" + + const abortHandler = () => { + try { + reader.cancel() + } catch { + // noop + } + } + + signal.addEventListener("abort", abortHandler) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += value + // Normalize line endings: CRLF -> LF, then CR -> LF + buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + + const chunks = buffer.split("\n\n") + buffer = chunks.pop() ?? "" + + for (const chunk of chunks) { + const lines = chunk.split("\n") + const dataLines: Array = [] + let eventName: string | undefined + + for (const line of lines) { + if (line.startsWith("data:")) { + dataLines.push(line.replace(/^data:\s*/, "")) + } else if (line.startsWith("event:")) { + eventName = line.replace(/^event:\s*/, "") + } else if (line.startsWith("id:")) { + lastEventId = line.replace(/^id:\s*/, "") + } else if (line.startsWith("retry:")) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) + if (!Number.isNaN(parsed)) { + retryDelay = parsed + } + } + } + + let data: unknown + let parsedJson = false + + if (dataLines.length) { + const rawData = dataLines.join("\n") + try { + data = JSON.parse(rawData) + parsedJson = true + } catch { + data = rawData + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data) + } + + if (responseTransformer) { + data = await responseTransformer(data) + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }) + + if (dataLines.length) { + yield data as any + } + } + } + } finally { + signal.removeEventListener("abort", abortHandler) + reader.releaseLock() + } + + break // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error) + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000) + await sleep(backoff) + } + } + } + + const stream = createStream() + + return { stream } +} diff --git a/packages/sdk/js/example/effect-sdk/gen/core/types.gen.ts b/packages/sdk/js/example/effect-sdk/gen/core/types.gen.ts new file mode 100644 index 00000000000..bfa77b8acd2 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/core/types.gen.ts @@ -0,0 +1,86 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from "./auth.gen.js" +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js" + +export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace" + +export type Client = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn + getConfig: () => Config + request: RequestFn + setConfig: (config: Config) => Config +} & { + [K in HttpMethod]: MethodFn +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }) + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit["headers"] + | Record + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K] +} diff --git a/packages/sdk/js/example/effect-sdk/gen/core/utils.gen.ts b/packages/sdk/js/example/effect-sdk/gen/core/utils.gen.ts new file mode 100644 index 00000000000..8a45f72698a --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/core/utils.gen.ts @@ -0,0 +1,137 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js" +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "./pathSerializer.gen.js" + +export interface PathSerializer { + path: Record + url: string +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url + const matches = _url.match(PATH_PARAM_RE) + if (matches) { + for (const match of matches) { + let explode = false + let name = match.substring(1, match.length - 1) + let style: ArraySeparatorStyle = "simple" + + if (name.endsWith("*")) { + explode = true + name = name.substring(0, name.length - 1) + } + + if (name.startsWith(".")) { + name = name.substring(1) + style = "label" + } else if (name.startsWith(";")) { + name = name.substring(1) + style = "matrix" + } + + const value = path[name] + + if (value === undefined || value === null) { + continue + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })) + continue + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ) + continue + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ) + continue + } + + const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string)) + url = url.replace(match, replaceValue) + } + } + return url +} + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string + path?: Record + query?: Record + querySerializer: QuerySerializer + url: string +}) => { + const pathUrl = _url.startsWith("/") ? _url : `/${_url}` + let url = (baseUrl ?? "") + pathUrl + if (path) { + url = defaultPathSerializer({ path, url }) + } + let search = query ? querySerializer(query) : "" + if (search.startsWith("?")) { + search = search.substring(1) + } + if (search) { + url += `?${search}` + } + return url +} + +export function getValidRequestBody(options: { + body?: unknown + bodySerializer?: BodySerializer | null + serializedBody?: unknown +}) { + const hasBody = options.body !== undefined + const isSerializedBody = hasBody && options.bodySerializer + + if (isSerializedBody) { + if ("serializedBody" in options) { + const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "" + + return hasSerializedBody ? options.serializedBody : null + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== "" ? options.body : null + } + + // plain/text body + if (hasBody) { + return options.body + } + + // no body was provided + return undefined +} diff --git a/packages/sdk/js/example/effect-sdk/gen/effect.gen.ts b/packages/sdk/js/example/effect-sdk/gen/effect.gen.ts new file mode 100644 index 00000000000..b98315b6c26 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/effect.gen.ts @@ -0,0 +1,94 @@ +// This file is auto-generated by oagen. Do not edit. + +import { Effect } from "effect" +import type { Client, Options, TDataShape } from "./client/index.js" +import type { ServerSentEventsResult } from "./core/serverSentEvents.gen.js" +import type { + CreateProjectTaskData, + CreateProjectTaskError, + CreateProjectTaskErrors, + CreateProjectTaskResponse, + CreateProjectTaskResponses, + ListProjectTasksData, + ListProjectTasksError, + ListProjectTasksErrors, + ListProjectTasksResponse, + ListProjectTasksResponses, + StreamProjectEventsData, + StreamProjectEventsResponse, + StreamProjectEventsResponses, +} from "./types.gen.js" + +export type ExampleEffectOptions = Omit< + Options, + "responseStyle" | "throwOnError" +> + +export interface ExampleEffectClient { + tasks: { + /** + * List tasks for a project. + */ + listProjectTasks( + options: ExampleEffectOptions, + ): Effect.Effect + /** + * Create a task in a project. + */ + createProjectTask( + options: ExampleEffectOptions, + ): Effect.Effect + } + events: { + /** + * Stream project events as server-sent events. + */ + streamProjectEvents( + options: ExampleEffectOptions, + ): Effect.Effect, Error> + } +} + +export function createExampleEffectClient(client: Client): ExampleEffectClient { + return { + tasks: { + listProjectTasks: (options) => + request(() => + client.get({ + url: "/projects/{projectId}/tasks", + ...options, + throwOnError: true, + responseStyle: "data", + }), + ), + createProjectTask: (options) => + request(() => + client.post({ + url: "/projects/{projectId}/tasks", + ...options, + throwOnError: true, + responseStyle: "data", + headers: { "Content-Type": "application/json", ...options?.headers }, + }), + ), + }, + events: { + streamProjectEvents: (options) => + request, never>(() => + client.sse.get({ + url: "/projects/{projectId}/events", + ...options, + throwOnError: true, + responseStyle: "data", + }), + ), + }, + } +} + +function request(evaluate: () => Promise) { + return Effect.tryPromise({ + try: evaluate, + catch: (error) => error as E | Error, + }) +} diff --git a/packages/sdk/js/example/effect-sdk/gen/types.gen.ts b/packages/sdk/js/example/effect-sdk/gen/types.gen.ts new file mode 100644 index 00000000000..f740b541e16 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/gen/types.gen.ts @@ -0,0 +1,93 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}) +} + +export type ApiError = { + message: string +} + +export type CreateTaskRequest = { + title: string +} + +export type Task = { + id: string + title: string + status: "open" | "closed" +} + +export type ListProjectTasksData = { + body?: never + path: { + projectId: string + } + query?: { + status?: "open" | "closed" + } + url: "/projects/{projectId}/tasks" +} + +export type ListProjectTasksErrors = { + /** + * Project not found. + */ + 404: ApiError +} + +export type ListProjectTasksError = ListProjectTasksErrors[keyof ListProjectTasksErrors] + +export type ListProjectTasksResponses = { + /** + * Task list. + */ + 200: Array +} + +export type ListProjectTasksResponse = ListProjectTasksResponses[keyof ListProjectTasksResponses] + +export type CreateProjectTaskData = { + body: CreateTaskRequest + path: { + projectId: string + } + query?: never + url: "/projects/{projectId}/tasks" +} + +export type CreateProjectTaskErrors = { + /** + * Invalid task. + */ + 400: ApiError +} + +export type CreateProjectTaskError = CreateProjectTaskErrors[keyof CreateProjectTaskErrors] + +export type CreateProjectTaskResponses = { + /** + * Created task. + */ + 201: Task +} + +export type CreateProjectTaskResponse = CreateProjectTaskResponses[keyof CreateProjectTaskResponses] + +export type StreamProjectEventsData = { + body?: never + path: { + projectId: string + } + query?: never + url: "/projects/{projectId}/events" +} + +export type StreamProjectEventsResponses = { + /** + * Event stream. + */ + 200: string +} + +export type StreamProjectEventsResponse = StreamProjectEventsResponses[keyof StreamProjectEventsResponses] diff --git a/packages/sdk/js/example/effect-sdk/openapi.json b/packages/sdk/js/example/effect-sdk/openapi.json new file mode 100644 index 00000000000..4eb87410160 --- /dev/null +++ b/packages/sdk/js/example/effect-sdk/openapi.json @@ -0,0 +1,174 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Example Effect SDK", + "version": "1.0.0" + }, + "paths": { + "/projects/{projectId}/tasks": { + "get": { + "operationId": "listProjectTasks", + "tags": ["Tasks"], + "description": "List tasks for a project.", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["open", "closed"] + } + } + ], + "responses": { + "200": { + "description": "Task list.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Task" + } + } + } + } + }, + "404": { + "description": "Project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "post": { + "operationId": "createProjectTask", + "tags": ["Tasks"], + "description": "Create a task in a project.", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "description": "Invalid task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/projects/{projectId}/events": { + "get": { + "operationId": "streamProjectEvents", + "tags": ["Events"], + "description": "Stream project events as server-sent events.", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Event stream.", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApiError": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + }, + "CreateTaskRequest": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string" + } + } + }, + "Task": { + "type": "object", + "required": ["id", "title", "status"], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["open", "closed"] + } + } + } + } + } +} diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 4e268bf0f94..7e68bd8a319 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -42,23 +42,7 @@ await createClient({ ], }) -// Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the -// endpoint's TError into the second generic of ServerSentEventsResult, which -// is the AsyncGenerator's TReturn slot. Iterator return values have nothing -// to do with HTTP errors, and any consumer that calls `.return()` or returns -// from a mock generator gets type-checked against the wrong shape. Drop the -// arg so TReturn defaults to void. -const sseTypesPath = "./src/v2/gen/client/types.gen.ts" -const sseTypesFile = Bun.file(sseTypesPath) -const sseTypesSource = await sseTypesFile.text() -const sseTypesPatched = sseTypesSource.replace( - "=> Promise>", - "=> Promise>", -) -if (sseTypesPatched === sseTypesSource) { - throw new Error(`SseFn patch did not apply; @hey-api/openapi-ts output may have changed (${sseTypesPath})`) -} -await Bun.write(sseTypesPath, sseTypesPatched) +await patchSseTypes("./src/v2/gen/client/types.gen.ts") const openapi = await Bun.file("./openapi.json").json() const effect = generateFiles(await parseSpec("./openapi.json"), effectEmitter, { @@ -72,12 +56,65 @@ for (const file of effect.files) { await Bun.write(path.join("./src/v2/gen", file.path), file.content) } +await createClient({ + input: "./example/effect-sdk/openapi.json", + output: { + path: "./example/effect-sdk/gen", + tsConfigPath: path.join(dir, "tsconfig.json"), + clean: true, + }, + plugins: [ + { + name: "@hey-api/typescript", + exportFromIndex: false, + }, + { + name: "@hey-api/client-fetch", + exportFromIndex: false, + baseUrl: "https://api.example.com", + }, + ], +}) +await patchSseTypes("./example/effect-sdk/gen/client/types.gen.ts") + +const exampleOpenapi = await Bun.file("./example/effect-sdk/openapi.json").json() +const exampleEffect = generateFiles(await parseSpec("./example/effect-sdk/openapi.json"), effectEmitter, { + namespace: "Example", + outputDir: "./example/effect-sdk/gen", + emitterOptions: { + serverSentEvents: serverSentEvents(exampleOpenapi), + }, +}) +for (const file of exampleEffect.files) { + await Bun.write(path.join("./example/effect-sdk/gen", file.path), file.content) +} + await $`bun prettier --write src/gen` await $`bun prettier --write src/v2` +await $`bun prettier --write example/effect-sdk/gen` await $`rm -rf dist` await $`bun tsc` await $`rm openapi.json` +async function patchSseTypes(sseTypesPath: string) { + // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the + // endpoint's TError into the second generic of ServerSentEventsResult, which + // is the AsyncGenerator's TReturn slot. Iterator return values have nothing + // to do with HTTP errors, and any consumer that calls `.return()` or returns + // from a mock generator gets type-checked against the wrong shape. Drop the + // arg so TReturn defaults to void. + const sseTypesFile = Bun.file(sseTypesPath) + const sseTypesSource = await sseTypesFile.text() + const sseTypesPatched = sseTypesSource.replace( + "=> Promise>", + "=> Promise>", + ) + if (sseTypesPatched === sseTypesSource) { + throw new Error(`SseFn patch did not apply; @hey-api/openapi-ts output may have changed (${sseTypesPath})`) + } + await Bun.write(sseTypesPath, sseTypesPatched) +} + function serverSentEvents(spec: unknown) { if (!spec || typeof spec !== "object" || !("paths" in spec) || !spec.paths || typeof spec.paths !== "object") return [] diff --git a/packages/sdk/js/script/effect-emitter.ts b/packages/sdk/js/script/effect-emitter.ts index cfb15bb4a4c..5989b2ec86a 100644 --- a/packages/sdk/js/script/effect-emitter.ts +++ b/packages/sdk/js/script/effect-emitter.ts @@ -57,13 +57,13 @@ function generateEffectClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile .map((name) => ` ${name},`), `} from "./types.gen.js"`, ``, - `export type OpencodeEffectOptions = Omit<`, + `export type ${ctx.namespacePascal}EffectOptions = Omit<`, ` Options,`, ` "responseStyle" | "throwOnError"`, `>`, ``, `export interface ${ctx.namespacePascal}EffectClient {`, - ...spec.services.flatMap((service) => serviceShape(service, sse)), + ...spec.services.flatMap((service) => serviceShape(service, sse, `${ctx.namespacePascal}EffectOptions`)), `}`, ``, `export function create${ctx.namespacePascal}EffectClient(client: Client): ${ctx.namespacePascal}EffectClient {`, @@ -82,10 +82,13 @@ function generateEffectClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile } } -function serviceShape(service: Service, sse: Set) { +function serviceShape(service: Service, sse: Set, optionsType: string) { return [ ` ${propertyName(service.name)}: {`, - ...service.operations.flatMap((operation) => [doc(operation, " "), ` ${methodSignature(operation, sse)}`]), + ...service.operations.flatMap((operation) => [ + doc(operation, " "), + ` ${methodSignature(operation, sse, optionsType)}`, + ]), ` }`, ] } @@ -100,9 +103,9 @@ function serviceFactory(service: Service, sse: Set) { ] } -function methodSignature(operation: Operation, sse: Set) { +function methodSignature(operation: Operation, sse: Set, optionsType: string) { const optional = hasRequiredOptions(operation) ? "" : "?" - return `${propertyName(operation.name)}(options${optional}: OpencodeEffectOptions<${operationType( + return `${propertyName(operation.name)}(options${optional}: ${optionsType}<${operationType( operation, )}Data, ${operationType(operation)}Responses>): Effect.Effect<${operationResponse(operation, sse)}, ${operationError( operation,