diff --git a/docs/openapi-ts/clients/angular.md b/docs/openapi-ts/clients/angular.md index a9bdacfd54..0041ce9a74 100644 --- a/docs/openapi-ts/clients/angular.md +++ b/docs/openapi-ts/clients/angular.md @@ -204,6 +204,157 @@ This section is under construction. We appreciate your patience. This section is under construction. We appreciate your patience. ::: +## Server-Sent Events + +When your OpenAPI spec defines endpoints with `text/event-stream` responses, the SDK generates SSE-enabled functions that return an async stream instead of a regular response. + +::: warning +Angular `HttpClient` interceptors do not apply to SSE connections. The SSE client uses the native Fetch API under the hood. +::: + +### Consuming a stream + +```js +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + console.log(event); +} +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + +### Angular component example + +When using the [`@Injectable` configuration](#injectable), SSE methods are instance methods on the generated service classes. + +```ts +// openapi-ts.config.ts +export default defineConfig({ + plugins: [ + '@hey-api/client-angular', + { + name: '@hey-api/sdk', + asClass: true, + operations: { + containerName: '{{name}}Service', + strategy: 'byTags', + }, + }, + ], +}); +``` + +The generated service has SSE methods alongside regular methods: + +```ts +// Generated: sdk.gen.ts +@Injectable({ providedIn: 'root' }) +export class StockService { + public watchStockPrices(options?) { + return (options?.client ?? client).sse.get<...>({ url: '/stock/watch', ...options }); + } +} +``` + +Inject the service in your component. Use signals for state and `ngOnDestroy` for cleanup. + +```ts +import { Component, inject, OnDestroy, signal } from '@angular/core'; +import { StockService } from './client/sdk.gen'; +import type { StockUpdate } from './client/types.gen'; + +@Component({ + selector: 'app-stock-ticker', + template: ` + + +

Status: {{ status() }}

+ + `, +}) +export class StockTickerComponent implements OnDestroy { + #stockService = inject(StockService); + + updates = signal([]); + status = signal<'connected' | 'disconnected' | 'error'>('disconnected'); + #controller: AbortController | null = null; + + async connect() { + this.#controller = new AbortController(); + this.status.set('connected'); + this.updates.set([]); + + try { + const { stream } = await this.#stockService.watchStockPrices({ + signal: this.#controller.signal, + }); + + for await (const event of stream) { + this.updates.update((prev) => [...prev, event]); + } + } catch { + if (!this.#controller?.signal.aborted) { + this.status.set('error'); + return; + } + } + this.status.set('disconnected'); + } + + disconnect() { + this.#controller?.abort(); + this.#controller = null; + this.status.set('disconnected'); + } + + ngOnDestroy() { + this.disconnect(); + } +} +``` + +### RxJS alternative + +If your codebase uses RxJS pipelines, you can wrap the async generator in an Observable. + +```ts +import { Observable } from 'rxjs'; +import { watchStockPrices } from './client/sdk.gen'; +import type { StockUpdate } from './client/types.gen'; + +function watchPrices$(): Observable { + return new Observable((subscriber) => { + const controller = new AbortController(); + + (async () => { + try { + const { stream } = await watchStockPrices({ + signal: controller.signal, + }); + + for await (const event of stream) { + subscriber.next(event); + } + subscriber.complete(); + } catch (error) { + if (!controller.signal.aborted) { + subscriber.error(error); + } + } + })(); + + return () => controller.abort(); + }); +} +``` + ## Build URL If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. diff --git a/docs/openapi-ts/clients/angular/v19.md b/docs/openapi-ts/clients/angular/v19.md index 4f40d0e8a6..c972255877 100644 --- a/docs/openapi-ts/clients/angular/v19.md +++ b/docs/openapi-ts/clients/angular/v19.md @@ -204,6 +204,157 @@ This section is under construction. We appreciate your patience. This section is under construction. We appreciate your patience. ::: +## Server-Sent Events + +When your OpenAPI spec defines endpoints with `text/event-stream` responses, the SDK generates SSE-enabled functions that return an async stream instead of a regular response. + +::: warning +Angular `HttpClient` interceptors do not apply to SSE connections. The SSE client uses the native Fetch API under the hood. +::: + +### Consuming a stream + +```js +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + console.log(event); +} +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + +### Angular component example + +When using the [`@Injectable` configuration](#injectable), SSE methods are instance methods on the generated service classes. + +```ts +// openapi-ts.config.ts +export default defineConfig({ + plugins: [ + '@hey-api/client-angular', + { + name: '@hey-api/sdk', + asClass: true, + operations: { + containerName: '{{name}}Service', + strategy: 'byTags', + }, + }, + ], +}); +``` + +The generated service has SSE methods alongside regular methods: + +```ts +// Generated: sdk.gen.ts +@Injectable({ providedIn: 'root' }) +export class StockService { + public watchStockPrices(options?) { + return (options?.client ?? client).sse.get<...>({ url: '/stock/watch', ...options }); + } +} +``` + +Inject the service in your component. Use signals for state and `ngOnDestroy` for cleanup. + +```ts +import { Component, inject, OnDestroy, signal } from '@angular/core'; +import { StockService } from './client/sdk.gen'; +import type { StockUpdate } from './client/types.gen'; + +@Component({ + selector: 'app-stock-ticker', + template: ` + + +

Status: {{ status() }}

+
    + @for (update of updates(); track $index) { +
  • {{ update | json }}
  • + } +
+ `, +}) +export class StockTickerComponent implements OnDestroy { + #stockService = inject(StockService); + + updates = signal([]); + status = signal<'connected' | 'disconnected' | 'error'>('disconnected'); + #controller: AbortController | null = null; + + async connect() { + this.#controller = new AbortController(); + this.status.set('connected'); + this.updates.set([]); + + try { + const { stream } = await this.#stockService.watchStockPrices({ + signal: this.#controller.signal, + }); + + for await (const event of stream) { + this.updates.update((prev) => [...prev, event]); + } + } catch { + if (!this.#controller?.signal.aborted) { + this.status.set('error'); + return; + } + } + this.status.set('disconnected'); + } + + disconnect() { + this.#controller?.abort(); + this.#controller = null; + this.status.set('disconnected'); + } + + ngOnDestroy() { + this.disconnect(); + } +} +``` + +### RxJS alternative + +If your codebase uses RxJS pipelines, you can wrap the async generator in an Observable. + +```ts +import { Observable } from 'rxjs'; +import { watchStockPrices } from './client/sdk.gen'; +import type { StockUpdate } from './client/types.gen'; + +function watchPrices$(): Observable { + return new Observable((subscriber) => { + const controller = new AbortController(); + + (async () => { + try { + const { stream } = await watchStockPrices({ + signal: controller.signal, + }); + + for await (const event of stream) { + subscriber.next(event); + } + subscriber.complete(); + } catch (error) { + if (!controller.signal.aborted) { + subscriber.error(error); + } + } + })(); + + return () => controller.abort(); + }); +} +``` + ## Build URL If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. diff --git a/docs/openapi-ts/clients/axios.md b/docs/openapi-ts/clients/axios.md index a31046b511..ba17023588 100644 --- a/docs/openapi-ts/clients/axios.md +++ b/docs/openapi-ts/clients/axios.md @@ -184,6 +184,28 @@ client.instance.interceptors.request.use((config) => { }); ``` +## Server-Sent Events + +When your OpenAPI spec defines endpoints with `text/event-stream` responses, the SDK generates SSE-enabled functions that return an async stream instead of a regular response. + +::: warning +Axios interceptors registered through `client.instance.interceptors` do not apply to SSE connections. The SSE client uses the native Fetch API under the hood. +::: + +### Consuming a stream + +```js +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + console.log(event); +} +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + ## Build URL If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. diff --git a/docs/openapi-ts/clients/fetch.md b/docs/openapi-ts/clients/fetch.md index 99fcd01268..c98a776a25 100644 --- a/docs/openapi-ts/clients/fetch.md +++ b/docs/openapi-ts/clients/fetch.md @@ -262,6 +262,28 @@ client.interceptors.request.use((request, options) => { }); ``` +## Server-Sent Events + +When your OpenAPI spec defines endpoints with `text/event-stream` responses, the SDK generates SSE-enabled functions that return an async stream instead of a regular response. + +::: info +Request interceptors registered through `client.interceptors.request` apply to SSE connections, including on each reconnect attempt. +::: + +### Consuming a stream + +```js +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + console.log(event); +} +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + ## Build URL If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. diff --git a/docs/openapi-ts/clients/ky.md b/docs/openapi-ts/clients/ky.md index e05a487865..3f46a238ef 100644 --- a/docs/openapi-ts/clients/ky.md +++ b/docs/openapi-ts/clients/ky.md @@ -260,6 +260,28 @@ client.interceptors.request.use((request, options) => { }); ``` +## Server-Sent Events + +When your OpenAPI spec defines endpoints with `text/event-stream` responses, the SDK generates SSE-enabled functions that return an async stream instead of a regular response. + +::: info +Request interceptors registered through `client.interceptors.request` apply to SSE connections, including on each reconnect attempt. +::: + +### Consuming a stream + +```js +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + console.log(event); +} +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + ## Build URL If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. diff --git a/docs/openapi-ts/clients/next-js.md b/docs/openapi-ts/clients/next-js.md index 50ef19b7a8..616d43637b 100644 --- a/docs/openapi-ts/clients/next-js.md +++ b/docs/openapi-ts/clients/next-js.md @@ -245,6 +245,150 @@ client.interceptors.request.use((options) => { }); ``` +## Server-Sent Events + +When your OpenAPI spec defines endpoints with `text/event-stream` responses, the SDK generates SSE-enabled functions that return an async stream instead of a regular response. + +### Consuming a stream + +```js +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + console.log(event); +} +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + +### React component example + +::: warning +Unlike regular SDK calls which support Next.js caching options (`cache`, `next: { revalidate, tags }`), SSE streams are long-lived connections and must run in client components. +::: + +```tsx +'use client'; + +import { useRef, useState } from 'react'; +import { watchStockPrices } from './client/sdk.gen'; +import type { StockUpdate } from './client/types.gen'; + +export function StockTicker() { + const [updates, setUpdates] = useState([]); + const controllerRef = useRef(null); + + const connect = async () => { + const controller = new AbortController(); + controllerRef.current = controller; + + try { + const { stream } = await watchStockPrices({ + signal: controller.signal, + }); + + for await (const event of stream) { + setUpdates((prev) => [...prev, event]); + } + } catch { + if (!controller.signal.aborted) { + console.error('Stream failed'); + } + } + }; + + const disconnect = () => { + controllerRef.current?.abort(); + controllerRef.current = null; + }; + + return ( + <> + + +
    + {updates.map((u, i) => ( +
  • {JSON.stringify(u)}
  • + ))} +
+ + ); +} +``` + +### Custom hook factory + +You can create a reusable factory that wraps any SSE SDK function into a React hook. + +```tsx +'use client'; + +import { useCallback, useRef, useState } from 'react'; + +export function createUseSse< + TFn extends (...args: any[]) => Promise<{ stream: AsyncGenerator }>, +>(sseFn: TFn) { + type TEvent = Awaited> extends { stream: AsyncGenerator } ? E : never; + type TOptions = Parameters[0]; + + return function useSse() { + const [events, setEvents] = useState([]); + const [status, setStatus] = useState<'connected' | 'disconnected' | 'error'>('disconnected'); + const controllerRef = useRef(null); + + const connect = useCallback(async (options?: Omit) => { + const controller = new AbortController(); + controllerRef.current = controller; + setStatus('connected'); + setEvents([]); + + try { + const { stream } = await sseFn({ + ...options, + signal: controller.signal, + } as TOptions); + + for await (const event of stream) { + setEvents((prev) => [...prev, event as TEvent]); + } + } catch { + if (!controller.signal.aborted) { + setStatus('error'); + return; + } + } + setStatus('disconnected'); + }, []); + + const disconnect = useCallback(() => { + controllerRef.current?.abort(); + controllerRef.current = null; + setStatus('disconnected'); + }, []); + + return { connect, disconnect, events, status }; + }; +} +``` + +```tsx +import { watchStockPrices } from './client/sdk.gen'; +import { createUseSse } from './hooks/createUseSse'; + +const useStockPrices = createUseSse(watchStockPrices); + +function StockTicker() { + const { events, status, connect, disconnect } = useStockPrices(); + // ... +} +``` + +::: tip +Request interceptors registered through `client.interceptors.request` apply to SSE connections, including on each reconnect attempt. +::: + ## Build URL If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. diff --git a/docs/openapi-ts/clients/nuxt.md b/docs/openapi-ts/clients/nuxt.md index ef8657b6a7..72d346ef18 100644 --- a/docs/openapi-ts/clients/nuxt.md +++ b/docs/openapi-ts/clients/nuxt.md @@ -216,6 +216,130 @@ client.setConfig({ }); ``` +## Server-Sent Events + +When your OpenAPI spec defines endpoints with `text/event-stream` responses, the SDK generates SSE-enabled functions that return an async stream instead of a regular response. + +::: warning +SSE endpoints always return `{ stream }` with an `AsyncGenerator`. The `composable` option (`useAsyncData`, `useFetch`, etc.) does not apply to SSE — it is designed for request-response patterns with caching. SSE streams are consumed client-side only. + +Nuxt interceptors (`onRequest`, `onResponse`) are also not applied to SSE connections. The SSE client handles connections directly using the native Fetch API. +::: + +### Consuming a stream + +```js +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + console.log(event); +} +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + +### Vue component example + +With the `@hey-api/nuxt` module, SDK functions are auto-imported. Vue refs passed as parameters are automatically unwrapped. + +```vue + + + +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + +### Custom composable + +For reusable SSE logic, extract a composable. + +```ts +import { onUnmounted, ref } from 'vue'; +import { watchStockPrices } from './client/sdk.gen'; +import type { StockUpdate } from './client/types.gen'; + +export function useStockStream() { + const updates = ref([]); + const status = ref<'connected' | 'disconnected' | 'error'>('disconnected'); + let controller: AbortController | null = null; + + async function connect() { + controller = new AbortController(); + status.value = 'connected'; + updates.value = []; + + try { + const { stream } = await watchStockPrices({ + signal: controller.signal, + }); + + for await (const event of stream) { + updates.value.push(event); + } + } catch { + if (!controller?.signal.aborted) { + status.value = 'error'; + return; + } + } + status.value = 'disconnected'; + } + + function disconnect() { + controller?.abort(); + controller = null; + status.value = 'disconnected'; + } + + onUnmounted(() => disconnect()); + + return { connect, disconnect, status, updates }; +} +``` + +```vue + +``` + ## Build URL If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. diff --git a/docs/openapi-ts/clients/ofetch.md b/docs/openapi-ts/clients/ofetch.md index 32c06bf772..d51e553b8d 100644 --- a/docs/openapi-ts/clients/ofetch.md +++ b/docs/openapi-ts/clients/ofetch.md @@ -291,6 +291,28 @@ client.setConfig({ }); ``` +## Server-Sent Events + +When your OpenAPI spec defines endpoints with `text/event-stream` responses, the SDK generates SSE-enabled functions that return an async stream instead of a regular response. + +::: warning +Request interceptors registered through `client.interceptors.request` apply to SSE connections, including on each reconnect attempt. Native ofetch hooks (`onRequest`, `onResponse`) do not apply — SSE uses the Fetch API directly. +::: + +### Consuming a stream + +```js +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + console.log(event); +} +``` + +For more details on how to use the SSE-enabled functions, refer to the [SDK documentation](/openapi-ts/plugins/sdk#server-sent-events). + ## Build URL If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. diff --git a/docs/openapi-ts/plugins/sdk.md b/docs/openapi-ts/plugins/sdk.md index b53b2ef8cc..074e7b6026 100644 --- a/docs/openapi-ts/plugins/sdk.md +++ b/docs/openapi-ts/plugins/sdk.md @@ -232,6 +232,93 @@ export default { The SDK plugin currently supports only the `bearer` and `basic` auth schemes. [Open an issue](https://github.com/hey-api/openapi-ts/issues) if you'd like support for additional mechanisms. ::: +## Server-Sent Events + +When your OpenAPI spec defines a response with the `text/event-stream` content type, the SDK plugin automatically generates SSE-enabled functions. No configuration is required. + +### Detection + +Given the following OpenAPI spec: + +```yaml +/stock/watch: + get: + operationId: watchStockPrices + responses: + '200': + content: + text/event-stream: + schema: + $ref: '#/components/schemas/StockUpdate' +``` + +The SDK generates a function that calls `client.sse.get()` instead of the regular `client.get()`: + +```ts +export const watchStockPrices = (options?) => + (options?.client ?? client).sse.get({ + url: '/stock/watch', + ...options, + }); +``` + +### Consuming a stream + +SSE functions return a `{ stream }` object containing an `AsyncGenerator`. Use `for await...of` to consume events: + +```ts +import { watchStockPrices } from './client/sdk.gen'; + +const { stream } = await watchStockPrices(); + +for await (const event of stream) { + // event is typed based on your schema + console.log(event); +} +``` + +### Callbacks + +You can use `onSseEvent` and `onSseError` callbacks for additional event and error processing. + +```js +const { stream } = await watchStockPrices({ + onSseEvent: (event) => { + // access event.data, event.event, event.id, event.retry + }, + onSseError: (error) => { + console.error('SSE error:', error); + }, +}); +``` + +### Cancellation + +Use an `AbortController` to cancel the stream. + +```js +const controller = new AbortController(); + +const { stream } = await watchStockPrices({ + signal: controller.signal, +}); + +// cancel the stream +controller.abort(); +``` + +### Retry + +SSE connections automatically reconnect on failure with exponential backoff. You can configure the retry behavior. + +```js +const { stream } = await watchStockPrices({ + sseDefaultRetryDelay: 3000, // initial retry delay (default: 3000ms) + sseMaxRetryAttempts: 5, // max retry attempts + sseMaxRetryDelay: 30000, // max retry delay cap (default: 30000ms) +}); +``` + ## Validators Validating data at runtime comes with a performance cost, which is why it's not enabled by default. To enable validation, set `validator` to `zod` or one of the available [validator plugins](/openapi-ts/validators). This will implicitly add the selected plugin with default values. diff --git a/examples/README.md b/examples/README.md index 6359053b1a..8ece10bc3c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,7 @@ This directory contains integration examples demonstrating how to use `@hey-api/ - **openapi-ts-nuxt** - Nuxt.js integration with plugin - **openapi-ts-ofetch** - Using ofetch client - **openapi-ts-openai** - OpenAI API integration +- **openapi-ts-sse** - Server-Sent Events (SSE) streaming with Fetch API client - **openapi-ts-pinia-colada** - Vue with Pinia Colada state management - **openapi-ts-sample** - Sample/template example (excluded from CI) - **openapi-ts-tanstack-angular-query-experimental** - Angular with TanStack Query diff --git a/examples/openapi-ts-sse/.gitignore b/examples/openapi-ts-sse/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/examples/openapi-ts-sse/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/openapi-ts-sse/index.html b/examples/openapi-ts-sse/index.html new file mode 100644 index 0000000000..a00d9b312a --- /dev/null +++ b/examples/openapi-ts-sse/index.html @@ -0,0 +1,12 @@ + + + + + + Hey API + SSE Example + + +
+ + + diff --git a/examples/openapi-ts-sse/openapi-ts.config.ts b/examples/openapi-ts-sse/openapi-ts.config.ts new file mode 100644 index 0000000000..32ab86b7d3 --- /dev/null +++ b/examples/openapi-ts-sse/openapi-ts.config.ts @@ -0,0 +1,22 @@ +import path from 'node:path'; + +import { defineConfig } from '@hey-api/openapi-ts'; + +export default defineConfig({ + input: path.resolve('..', '..', 'specs', '3.1.x', 'sse-example.yaml'), + logs: { + path: './logs', + }, + output: { + path: './src/client', + postProcess: ['oxfmt'], + }, + plugins: [ + '@hey-api/client-fetch', + '@hey-api/sdk', + { + enums: 'javascript', + name: '@hey-api/typescript', + }, + ], +}); diff --git a/examples/openapi-ts-sse/package.json b/examples/openapi-ts-sse/package.json new file mode 100644 index 0000000000..f44a5ae550 --- /dev/null +++ b/examples/openapi-ts-sse/package.json @@ -0,0 +1,26 @@ +{ + "name": "@example/openapi-ts-sse", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "tsgo --noEmit && vite build", + "dev": "vite", + "openapi-ts": "openapi-ts", + "preview": "vite preview", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@hey-api/openapi-ts": "workspace:*", + "@types/react": "19.0.1", + "@types/react-dom": "19.0.1", + "@vitejs/plugin-react": "4.4.0-beta.1", + "oxfmt": "0.27.0", + "typescript": "5.9.3", + "vite": "7.3.1" + } +} diff --git a/examples/openapi-ts-sse/src/App.tsx b/examples/openapi-ts-sse/src/App.tsx new file mode 100644 index 0000000000..2395490c88 --- /dev/null +++ b/examples/openapi-ts-sse/src/App.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from 'react'; + +import { + getStockHistory, + watchSelectedStocks, + watchSingleStock, + watchStockPrices, +} from './client/sdk.gen'; +import type { StockUpdate } from './client/types.gen'; + +function App() { + const [updates, setUpdates] = useState>([]); + const [status, setStatus] = useState<'connected' | 'disconnected' | 'error'>('disconnected'); + const controllerRef = useRef(null); + + // Basic stream consumption using for-await-of + const onWatchAll = async () => { + const controller = new AbortController(); + controllerRef.current = controller; + setStatus('connected'); + setUpdates([]); + + try { + const { stream } = await watchStockPrices({ + signal: controller.signal, + }); + + for await (const update of stream) { + setUpdates((prev) => [...prev, update]); + } + } catch { + if (!controller.signal.aborted) { + setStatus('error'); + return; + } + } + setStatus('disconnected'); + }; + + // POST-based SSE with request body and callbacks + const onWatchSelected = async () => { + const controller = new AbortController(); + controllerRef.current = controller; + setStatus('connected'); + setUpdates([]); + + try { + const { stream } = await watchSelectedStocks({ + body: { + symbols: ['AAPL', 'GOOG', 'MSFT'], + }, + // callback invoked on network or parsing errors + onSseError: (error) => { + console.error('SSE error:', error); + }, + // low-level callback for each raw SSE event. + // for typed data, use the stream's for-await-of loop below instead. + onSseEvent: (event) => { + console.log('SSE event:', event.data, event.event, event.id); + }, + signal: controller.signal, + // retry configuration + sseDefaultRetryDelay: 3000, + sseMaxRetryAttempts: 5, + sseMaxRetryDelay: 30000, + }); + + for await (const update of stream) { + setUpdates((prev) => [...prev, update]); + } + } catch { + if (!controller.signal.aborted) { + setStatus('error'); + return; + } + } + setStatus('disconnected'); + }; + + // SSE with path parameter, query parameter, and error response + const onWatchSingle = async () => { + const controller = new AbortController(); + controllerRef.current = controller; + setStatus('connected'); + setUpdates([]); + + try { + const { stream } = await watchSingleStock({ + path: { symbol: 'AAPL' }, + query: { interval: 2 }, + signal: controller.signal, + }); + + for await (const update of stream) { + setUpdates((prev) => [...prev, update]); + } + } catch { + if (!controller.signal.aborted) { + setStatus('error'); + return; + } + } + setStatus('disconnected'); + }; + + // Cancel the stream using AbortController + const onDisconnect = () => { + controllerRef.current?.abort(); + controllerRef.current = null; + setStatus('disconnected'); + }; + + // Regular (non-SSE) endpoint for comparison + const onLoadHistory = async () => { + const { data, error } = await getStockHistory({ + query: { limit: 10 }, + }); + if (error) { + console.error(error); + return; + } + if (data) { + setUpdates(data); + } + }; + + return ( +
+

Hey API + SSE Example

+

+ Status: {status} +

+
+ + + + + +
+

Updates ({updates.length})

+
    + {updates.map((update, i) => ( +
  • + [{update.type}]{' '} + {update.type === 'price_change' && `${update.symbol} $${update.price}`} + {update.type === 'trade_executed' && + `${update.symbol} ${update.quantity}x @ $${update.price}`} + {update.type === 'market_alert' && `[${update.severity}] ${update.message}`} +
  • + ))} +
+
+ ); +} + +export default App; diff --git a/examples/openapi-ts-sse/src/client/client.gen.ts b/examples/openapi-ts-sse/src/client/client.gen.ts new file mode 100644 index 0000000000..8b9037c59c --- /dev/null +++ b/examples/openapi-ts-sse/src/client/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'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * 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()); diff --git a/examples/openapi-ts-sse/src/client/client/client.gen.ts b/examples/openapi-ts-sse/src/client/client/client.gen.ts new file mode 100644 index 0000000000..14dc0a0ec5 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/client/client.gen.ts @@ -0,0 +1,290 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +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 as string | 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) as string | undefined; + } + + // 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, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _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/examples/openapi-ts-sse/src/client/client/index.ts b/examples/openapi-ts-sse/src/client/client/index.ts new file mode 100644 index 0000000000..b295edeca0 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/examples/openapi-ts-sse/src/client/client/types.gen.ts b/examples/openapi-ts-sse/src/client/client/types.gen.ts new file mode 100644 index 0000000000..f20b4373ff --- /dev/null +++ b/examples/openapi-ts-sse/src/client/client/types.gen.ts @@ -0,0 +1,211 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { ServerSentEventsOptions, ServerSentEventsResult } from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +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, + | 'onRequest' + | '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< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/examples/openapi-ts-sse/src/client/client/utils.gen.ts b/examples/openapi-ts-sse/src/client/client/utils.gen.ts new file mode 100644 index 0000000000..5162192d8a --- /dev/null +++ b/examples/openapi-ts-sse/src/client/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +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< + Req, + Res, + Err, + Options +> => ({ + 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/examples/openapi-ts-sse/src/client/core/auth.gen.ts b/examples/openapi-ts-sse/src/client/core/auth.gen.ts new file mode 100644 index 0000000000..3ebf994788 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/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/examples/openapi-ts-sse/src/client/core/bodySerializer.gen.ts b/examples/openapi-ts-sse/src/client/core/bodySerializer.gen.ts new file mode 100644 index 0000000000..67daca60f8 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/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'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +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: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).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: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).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/examples/openapi-ts-sse/src/client/core/params.gen.ts b/examples/openapi-ts-sse/src/client/core/params.gen.ts new file mode 100644 index 0000000000..7955601a5c --- /dev/null +++ b/examples/openapi-ts-sse/src/client/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' && !Array.isArray(value) && !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/examples/openapi-ts-sse/src/client/core/pathSerializer.gen.ts b/examples/openapi-ts-sse/src/client/core/pathSerializer.gen.ts new file mode 100644 index 0000000000..994b2848c6 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// 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/examples/openapi-ts-sse/src/client/core/queryKeySerializer.gen.ts b/examples/openapi-ts-sse/src/client/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000000..5000df606f --- /dev/null +++ b/examples/openapi-ts-sse/src/client/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// 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/examples/openapi-ts-sse/src/client/core/serverSentEvents.gen.ts b/examples/openapi-ts-sse/src/client/core/serverSentEvents.gen.ts new file mode 100644 index 0000000000..6aa6cf02a4 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/core/serverSentEvents.gen.ts @@ -0,0 +1,243 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +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 extends Record ? 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/examples/openapi-ts-sse/src/client/core/types.gen.ts b/examples/openapi-ts-sse/src/client/core/types.gen.ts new file mode 100644 index 0000000000..9efe71d4c1 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/core/types.gen.ts @@ -0,0 +1,104 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * 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< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * 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/examples/openapi-ts-sse/src/client/core/utils.gen.ts b/examples/openapi-ts-sse/src/client/core/utils.gen.ts new file mode 100644 index 0000000000..9a4fec7830 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +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/examples/openapi-ts-sse/src/client/index.ts b/examples/openapi-ts-sse/src/client/index.ts new file mode 100644 index 0000000000..84c4bc8b66 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/index.ts @@ -0,0 +1,32 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { + getStockHistory, + type Options, + watchSelectedStocks, + watchSingleStock, + watchStockPrices, +} from './sdk.gen'; +export type { + ClientOptions, + ErrorResponse, + GetStockHistoryData, + GetStockHistoryResponse, + GetStockHistoryResponses, + MarketAlert, + PriceChange, + StockUpdate, + TradeExecuted, + WatchlistRequest, + WatchSelectedStocksData, + WatchSelectedStocksResponse, + WatchSelectedStocksResponses, + WatchSingleStockData, + WatchSingleStockError, + WatchSingleStockErrors, + WatchSingleStockResponse, + WatchSingleStockResponses, + WatchStockPricesData, + WatchStockPricesResponse, + WatchStockPricesResponses, +} from './types.gen'; diff --git a/examples/openapi-ts-sse/src/client/sdk.gen.ts b/examples/openapi-ts-sse/src/client/sdk.gen.ts new file mode 100644 index 0000000000..548e7b6966 --- /dev/null +++ b/examples/openapi-ts-sse/src/client/sdk.gen.ts @@ -0,0 +1,89 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; +import type { + GetStockHistoryData, + GetStockHistoryResponses, + WatchSelectedStocksData, + WatchSelectedStocksResponses, + WatchSingleStockData, + WatchSingleStockErrors, + WatchSingleStockResponses, + WatchStockPricesData, + WatchStockPricesResponses, +} from './types.gen'; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, +> = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +/** + * Watch stock prices + * + * Stream real-time stock price updates. + */ +export const watchStockPrices = ( + options?: Options, +) => + (options?.client ?? client).sse.get({ + url: '/stock/watch', + ...options, + }); + +/** + * Watch selected stocks + * + * Stream real-time price updates for selected stock symbols. + */ +export const watchSelectedStocks = ( + options: Options, +) => + (options.client ?? client).sse.post({ + url: '/stock/watchlist', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + +/** + * Watch a single stock + * + * Stream real-time price updates for a specific stock symbol. + */ +export const watchSingleStock = ( + options: Options, +) => + (options.client ?? client).sse.get< + WatchSingleStockResponses, + WatchSingleStockErrors, + ThrowOnError + >({ url: '/stock/{symbol}/watch', ...options }); + +/** + * Get stock history + * + * Retrieve historical stock price data. + */ +export const getStockHistory = ( + options?: Options, +) => + (options?.client ?? client).get({ + url: '/stock/history', + ...options, + }); diff --git a/examples/openapi-ts-sse/src/client/types.gen.ts b/examples/openapi-ts-sse/src/client/types.gen.ts new file mode 100644 index 0000000000..03d11cbdea --- /dev/null +++ b/examples/openapi-ts-sse/src/client/types.gen.ts @@ -0,0 +1,139 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; + +export type WatchlistRequest = { + /** + * List of stock symbols to watch (e.g. AAPL, GOOG) + */ + symbols: Array; +}; + +export type StockUpdate = + | ({ + type: 'price_change'; + } & PriceChange) + | ({ + type: 'trade_executed'; + } & TradeExecuted) + | ({ + type: 'market_alert'; + } & MarketAlert); + +export type PriceChange = { + change?: number; + price: number; + symbol: string; + timestamp: string; + type: 'price_change'; +}; + +export type TradeExecuted = { + price: number; + quantity: number; + symbol: string; + timestamp: string; + type: 'trade_executed'; +}; + +export type MarketAlert = { + message: string; + severity: 'info' | 'warning' | 'critical'; + type: 'market_alert'; +}; + +export type ErrorResponse = { + code: string; + message: string; +}; + +export type WatchStockPricesData = { + body?: never; + path?: never; + query?: never; + url: '/stock/watch'; +}; + +export type WatchStockPricesResponses = { + /** + * Real-time stock price stream + */ + 200: StockUpdate; +}; + +export type WatchStockPricesResponse = WatchStockPricesResponses[keyof WatchStockPricesResponses]; + +export type WatchSelectedStocksData = { + body: WatchlistRequest; + path?: never; + query?: never; + url: '/stock/watchlist'; +}; + +export type WatchSelectedStocksResponses = { + /** + * Filtered stock price stream + */ + 200: StockUpdate; +}; + +export type WatchSelectedStocksResponse = + WatchSelectedStocksResponses[keyof WatchSelectedStocksResponses]; + +export type WatchSingleStockData = { + body?: never; + path: { + /** + * Stock ticker symbol (e.g. AAPL) + */ + symbol: string; + }; + query?: { + /** + * Update interval in seconds + */ + interval?: number; + }; + url: '/stock/{symbol}/watch'; +}; + +export type WatchSingleStockErrors = { + /** + * Stock symbol not found + */ + 404: ErrorResponse; +}; + +export type WatchSingleStockError = WatchSingleStockErrors[keyof WatchSingleStockErrors]; + +export type WatchSingleStockResponses = { + /** + * Stock price stream for the requested symbol + */ + 200: StockUpdate; +}; + +export type WatchSingleStockResponse = WatchSingleStockResponses[keyof WatchSingleStockResponses]; + +export type GetStockHistoryData = { + body?: never; + path?: never; + query?: { + /** + * Maximum number of records to return + */ + limit?: number; + }; + url: '/stock/history'; +}; + +export type GetStockHistoryResponses = { + /** + * List of historical stock updates + */ + 200: Array; +}; + +export type GetStockHistoryResponse = GetStockHistoryResponses[keyof GetStockHistoryResponses]; diff --git a/examples/openapi-ts-sse/src/main.tsx b/examples/openapi-ts-sse/src/main.tsx new file mode 100644 index 0000000000..5b0c14c765 --- /dev/null +++ b/examples/openapi-ts-sse/src/main.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import App from './App.tsx'; +import { client } from './client/client.gen'; + +client.setConfig({ + baseUrl: 'http://localhost:3000', +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/openapi-ts-sse/tsconfig.json b/examples/openapi-ts-sse/tsconfig.json new file mode 100644 index 0000000000..04664de395 --- /dev/null +++ b/examples/openapi-ts-sse/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/openapi-ts-sse/tsconfig.node.json b/examples/openapi-ts-sse/tsconfig.node.json new file mode 100644 index 0000000000..97ede7ee6f --- /dev/null +++ b/examples/openapi-ts-sse/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/openapi-ts-sse/vite.config.ts b/examples/openapi-ts-sse/vite.config.ts new file mode 100644 index 0000000000..06085adc27 --- /dev/null +++ b/examples/openapi-ts-sse/vite.config.ts @@ -0,0 +1,18 @@ +import react from '@vitejs/plugin-react'; + +/** @type {import('vite').UserConfig} */ +export default { + build: { + sourcemap: true, + target: 'esnext', + }, + esbuild: { + target: 'esnext', + }, + optimizeDeps: { + esbuildOptions: { + target: 'esnext', + }, + }, + plugins: [react()], +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72ef325ade..c3f72e64d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -948,6 +948,37 @@ importers: specifier: 3.2.4 version: 3.2.4(typescript@5.9.3) + examples/openapi-ts-sse: + dependencies: + react: + specifier: 19.0.0 + version: 19.0.0 + react-dom: + specifier: 19.0.0 + version: 19.0.0(react@19.0.0) + devDependencies: + '@hey-api/openapi-ts': + specifier: workspace:* + version: link:../../packages/openapi-ts + '@types/react': + specifier: 19.0.1 + version: 19.0.1 + '@types/react-dom': + specifier: 19.0.1 + version: 19.0.1 + '@vitejs/plugin-react': + specifier: 4.4.0-beta.1 + version: 4.4.0-beta.1(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + oxfmt: + specifier: 0.27.0 + version: 0.27.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.3.1 + version: 7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + examples/openapi-ts-tanstack-angular-query-experimental: dependencies: '@angular/animations': @@ -1312,7 +1343,7 @@ importers: version: 1.8.0 nuxt: specifier: '>=3.0.0' - version: 3.14.1592(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@25.2.1)(db0@0.3.2)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.7.0)(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@3.29.5)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3)) + version: 3.14.1592(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@25.2.1)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@3.29.5)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3)) vue: specifier: '>=3.5.13' version: 3.5.13(typescript@5.9.3) @@ -1450,7 +1481,7 @@ importers: version: 1.14.3 nuxt: specifier: 3.14.1592 - version: 3.14.1592(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@25.2.1)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.1(jiti@2.6.1))(ioredis@5.9.2)(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@4.56.0)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3)) + version: 3.14.1592(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@25.2.1)(db0@0.3.2)(encoding@0.1.13)(eslint@9.39.1(jiti@2.6.1))(ioredis@5.7.0)(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@4.56.0)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1))(vue-tsc@3.2.4(typescript@5.9.3)) ofetch: specifier: 1.5.1 version: 1.5.1 @@ -17558,15 +17589,15 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.3)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.3)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.28.5)': dependencies: @@ -19823,6 +19854,15 @@ snapshots: '@nuxt/devalue@2.0.2': {} + '@nuxt/devtools-kit@1.7.0(magicast@0.3.5)(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1))': + dependencies: + '@nuxt/kit': 3.21.0(magicast@0.3.5) + '@nuxt/schema': 3.16.2 + execa: 7.2.0 + vite: 5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1) + transitivePeerDependencies: + - magicast + '@nuxt/devtools-kit@1.7.0(magicast@0.3.5)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@nuxt/kit': 3.21.0(magicast@0.3.5) @@ -19919,6 +19959,53 @@ snapshots: - utf-8-validate - vue + '@nuxt/devtools@1.7.0(rollup@4.56.0)(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1))(vue@3.5.25(typescript@5.9.3))': + dependencies: + '@antfu/utils': 0.7.10 + '@nuxt/devtools-kit': 1.7.0(magicast@0.3.5)(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)) + '@nuxt/devtools-wizard': 1.7.0 + '@nuxt/kit': 3.21.0(magicast@0.3.5) + '@vue/devtools-core': 7.6.8(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1))(vue@3.5.25(typescript@5.9.3)) + '@vue/devtools-kit': 7.6.8 + birpc: 0.2.19 + consola: 3.4.2 + cronstrue: 2.59.0 + destr: 2.0.5 + error-stack-parser-es: 0.1.5 + execa: 7.2.0 + fast-npm-meta: 0.2.2 + flatted: 3.3.3 + get-port-please: 3.2.0 + hookable: 5.5.3 + image-meta: 0.2.1 + is-installed-globally: 1.0.0 + launch-editor: 2.11.1 + local-pkg: 0.5.1 + magicast: 0.3.5 + nypm: 0.4.1 + ohash: 1.1.6 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.3.1 + rc9: 2.1.2 + scule: 1.3.0 + semver: 7.7.3 + simple-git: 3.28.0 + sirv: 3.0.2 + tinyglobby: 0.2.15 + unimport: 3.14.6(rollup@4.56.0) + vite: 5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1) + vite-plugin-inspect: 0.8.9(@nuxt/kit@3.21.0(magicast@0.3.5))(rollup@4.56.0)(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)) + vite-plugin-vue-inspector: 5.3.2(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)) + which: 3.0.1 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - rollup + - supports-color + - utf-8-validate + - vue + '@nuxt/devtools@1.7.0(rollup@4.56.0)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': dependencies: '@antfu/utils': 0.7.10 @@ -22533,8 +22620,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -22699,7 +22786,7 @@ snapshots: '@types/react@19.0.1': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/resolve@1.20.2': {} @@ -23221,9 +23308,9 @@ snapshots: '@vitejs/plugin-react@4.4.0-beta.1(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@babel/core': 7.28.3 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.3) + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 vite: 7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -23622,6 +23709,18 @@ snapshots: dependencies: '@vue/devtools-kit': 8.0.5 + '@vue/devtools-core@7.6.8(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1))(vue@3.5.25(typescript@5.9.3))': + dependencies: + '@vue/devtools-kit': 7.7.7 + '@vue/devtools-shared': 7.7.7 + mitt: 3.0.1 + nanoid: 5.1.5 + pathe: 1.1.2 + vite-hot-client: 0.2.4(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)) + vue: 3.5.25(typescript@5.9.3) + transitivePeerDependencies: + - vite + '@vue/devtools-core@7.6.8(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 7.7.7 @@ -27478,7 +27577,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 @@ -28957,14 +29056,14 @@ snapshots: nuxi@3.28.0: {} - nuxt@3.14.1592(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@25.2.1)(db0@0.3.2)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.7.0)(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@3.29.5)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3)): + nuxt@3.14.1592(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@25.2.1)(db0@0.3.2)(encoding@0.1.13)(eslint@9.39.1(jiti@2.6.1))(ioredis@5.7.0)(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@4.56.0)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1))(vue-tsc@3.2.4(typescript@5.9.3)): dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 1.7.0(rollup@3.29.5)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) - '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@3.29.5) - '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@3.29.5) + '@nuxt/devtools': 1.7.0(rollup@4.56.0)(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1))(vue@3.5.25(typescript@5.9.3)) + '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.56.0) + '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.56.0) '@nuxt/telemetry': 2.6.6(magicast@0.3.5) - '@nuxt/vite-builder': 3.14.1592(@types/node@25.2.1)(eslint@9.39.2(jiti@2.6.1))(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@3.29.5)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vue-tsc@3.2.4(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)) + '@nuxt/vite-builder': 3.14.1592(@types/node@25.2.1)(eslint@9.39.1(jiti@2.6.1))(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@4.56.0)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vue-tsc@3.2.4(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)) '@unhead/dom': 1.11.20 '@unhead/shared': 1.11.20 '@unhead/ssr': 1.11.20 @@ -28987,7 +29086,7 @@ snapshots: h3: 1.15.4 hookable: 5.5.3 ignore: 6.0.2 - impound: 0.2.2(rollup@3.29.5) + impound: 0.2.2(rollup@4.56.0) jiti: 2.6.1 klona: 2.0.6 knitwork: 1.3.0 @@ -29014,15 +29113,15 @@ snapshots: unctx: 2.4.1 unenv: 1.10.0 unhead: 1.11.20 - unimport: 3.14.6(rollup@3.29.5) + unimport: 3.14.6(rollup@4.56.0) unplugin: 1.16.1 - unplugin-vue-router: 0.10.9(rollup@3.29.5)(vue-router@4.5.0(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) + unplugin-vue-router: 0.10.9(rollup@4.56.0)(vue-router@4.5.0(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) unstorage: 1.17.0(@netlify/blobs@9.1.2)(db0@0.3.2)(ioredis@5.7.0) untyped: 1.5.2 vue: 3.5.25(typescript@5.9.3) vue-bundle-renderer: 2.1.2 vue-devtools-stub: 0.1.0 - vue-router: 4.5.0(vue@3.5.13(typescript@5.9.3)) + vue-router: 4.5.0(vue@3.5.25(typescript@5.9.3)) optionalDependencies: '@parcel/watcher': 2.5.1 '@types/node': 25.2.1 @@ -29199,6 +29298,127 @@ snapshots: - vue-tsc - xml2js + nuxt@3.14.1592(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@25.2.1)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@3.29.5)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3)): + dependencies: + '@nuxt/devalue': 2.0.2 + '@nuxt/devtools': 1.7.0(rollup@3.29.5)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) + '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@3.29.5) + '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@3.29.5) + '@nuxt/telemetry': 2.6.6(magicast@0.3.5) + '@nuxt/vite-builder': 3.14.1592(@types/node@25.2.1)(eslint@9.39.2(jiti@2.6.1))(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@3.29.5)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vue-tsc@3.2.4(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3)) + '@unhead/dom': 1.11.20 + '@unhead/shared': 1.11.20 + '@unhead/ssr': 1.11.20 + '@unhead/vue': 1.11.20(vue@3.5.25(typescript@5.9.3)) + '@vue/shared': 3.5.25 + acorn: 8.14.0 + c12: 2.0.1(magicast@0.3.5) + chokidar: 4.0.3 + compatx: 0.1.8 + consola: 3.4.2 + cookie-es: 1.2.2 + defu: 6.1.4 + destr: 2.0.5 + devalue: 5.3.2 + errx: 0.1.0 + esbuild: 0.24.2 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + globby: 14.1.0 + h3: 1.15.4 + hookable: 5.5.3 + ignore: 6.0.2 + impound: 0.2.2(rollup@3.29.5) + jiti: 2.6.1 + klona: 2.0.6 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.0 + nanotar: 0.1.1 + nitropack: 2.12.4(@netlify/blobs@9.1.2)(encoding@0.1.13)(rolldown@1.0.0-rc.9) + nuxi: 3.28.0 + nypm: 0.3.12 + ofetch: 1.5.1 + ohash: 1.1.6 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.3.1 + radix3: 1.1.2 + scule: 1.3.0 + semver: 7.7.3 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinyglobby: 0.2.10 + ufo: 1.6.1 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.4.1 + unenv: 1.10.0 + unhead: 1.11.20 + unimport: 3.14.6(rollup@3.29.5) + unplugin: 1.16.1 + unplugin-vue-router: 0.10.9(rollup@3.29.5)(vue-router@4.5.0(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) + unstorage: 1.17.0(@netlify/blobs@9.1.2)(db0@0.3.4)(ioredis@5.9.2) + untyped: 1.5.2 + vue: 3.5.25(typescript@5.9.3) + vue-bundle-renderer: 2.1.2 + vue-devtools-stub: 0.1.0 + vue-router: 4.5.0(vue@3.5.25(typescript@5.9.3)) + optionalDependencies: + '@parcel/watcher': 2.5.1 + '@types/node': 25.2.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@biomejs/biome' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - bufferutil + - db0 + - drizzle-orm + - encoding + - eslint + - idb-keyval + - ioredis + - less + - lightningcss + - magicast + - meow + - mysql2 + - optionator + - rolldown + - rollup + - sass + - sass-embedded + - sqlite3 + - stylelint + - stylus + - sugarss + - supports-color + - terser + - typescript + - uploadthing + - utf-8-validate + - vite + - vls + - vti + - vue-tsc + - xml2js + nuxt@3.14.1592(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@25.2.1)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(less@4.4.2)(magicast@0.3.5)(optionator@0.9.4)(rolldown@1.0.0-rc.9)(rollup@4.56.0)(sass@1.97.1)(terser@5.44.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3)): dependencies: '@nuxt/devalue': 2.0.2 @@ -32557,7 +32777,7 @@ snapshots: unplugin: 2.0.0-beta.1 yaml: 2.8.2 optionalDependencies: - vue-router: 4.5.0(vue@3.5.13(typescript@5.9.3)) + vue-router: 4.5.0(vue@3.5.25(typescript@5.9.3)) transitivePeerDependencies: - rollup - vue @@ -32867,6 +33087,10 @@ snapshots: vite: 7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-hot-client: 2.1.0(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vite-hot-client@0.2.4(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)): + dependencies: + vite: 5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1) + vite-hot-client@0.2.4(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: vite: 7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -32998,6 +33222,24 @@ snapshots: - rollup - supports-color + vite-plugin-inspect@0.8.9(@nuxt/kit@3.21.0(magicast@0.3.5))(rollup@4.56.0)(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.2.0(rollup@4.56.0) + debug: 4.4.3 + error-stack-parser-es: 0.1.5 + fs-extra: 11.3.1 + open: 10.2.0 + perfect-debounce: 1.0.0 + picocolors: 1.1.1 + sirv: 3.0.2 + vite: 5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1) + optionalDependencies: + '@nuxt/kit': 3.21.0(magicast@0.3.5) + transitivePeerDependencies: + - rollup + - supports-color + vite-plugin-inspect@0.8.9(@nuxt/kit@3.21.0(magicast@0.3.5))(rollup@4.56.0)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@antfu/utils': 0.7.10 @@ -33063,6 +33305,21 @@ snapshots: - supports-color - vue + vite-plugin-vue-inspector@5.3.2(vite@5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)): + dependencies: + '@babel/core': 7.28.3 + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.3) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.3) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.3) + '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.3) + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.3) + '@vue/compiler-dom': 3.5.25 + kolorist: 1.8.0 + magic-string: 0.30.21 + vite: 5.4.19(@types/node@25.2.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1) + transitivePeerDependencies: + - supports-color + vite-plugin-vue-inspector@5.3.2(vite@7.3.1(@types/node@24.10.10)(jiti@2.6.1)(less@4.4.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@babel/core': 7.28.3 diff --git a/specs/3.1.x/sse-example.yaml b/specs/3.1.x/sse-example.yaml new file mode 100644 index 0000000000..08086c0045 --- /dev/null +++ b/specs/3.1.x/sse-example.yaml @@ -0,0 +1,180 @@ +openapi: 3.1.0 +info: + title: Stock Ticker API + description: Example API demonstrating Server-Sent Events (SSE) with a stock ticker domain + version: 1.0.0 +paths: + /stock/watch: + get: + operationId: watchStockPrices + summary: Watch stock prices + description: Stream real-time stock price updates. + responses: + '200': + description: Real-time stock price stream + content: + text/event-stream: + schema: + $ref: '#/components/schemas/StockUpdate' + /stock/watchlist: + post: + operationId: watchSelectedStocks + summary: Watch selected stocks + description: Stream real-time price updates for selected stock symbols. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WatchlistRequest' + responses: + '200': + description: Filtered stock price stream + content: + text/event-stream: + schema: + $ref: '#/components/schemas/StockUpdate' + /stock/{symbol}/watch: + get: + operationId: watchSingleStock + summary: Watch a single stock + description: Stream real-time price updates for a specific stock symbol. + parameters: + - name: symbol + in: path + required: true + description: Stock ticker symbol (e.g. AAPL) + schema: + type: string + - name: interval + in: query + description: Update interval in seconds + schema: + type: integer + default: 5 + responses: + '200': + description: Stock price stream for the requested symbol + content: + text/event-stream: + schema: + $ref: '#/components/schemas/StockUpdate' + '404': + description: Stock symbol not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /stock/history: + get: + operationId: getStockHistory + summary: Get stock history + description: Retrieve historical stock price data. + parameters: + - name: limit + in: query + description: Maximum number of records to return + schema: + type: integer + default: 50 + responses: + '200': + description: List of historical stock updates + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StockUpdate' +components: + schemas: + WatchlistRequest: + type: object + required: + - symbols + properties: + symbols: + type: array + items: + type: string + description: List of stock symbols to watch (e.g. AAPL, GOOG) + StockUpdate: + oneOf: + - $ref: '#/components/schemas/PriceChange' + - $ref: '#/components/schemas/TradeExecuted' + - $ref: '#/components/schemas/MarketAlert' + discriminator: + propertyName: type + mapping: + price_change: '#/components/schemas/PriceChange' + trade_executed: '#/components/schemas/TradeExecuted' + market_alert: '#/components/schemas/MarketAlert' + PriceChange: + type: object + required: + - type + - symbol + - price + - timestamp + properties: + type: + type: string + const: price_change + symbol: + type: string + price: + type: number + change: + type: number + timestamp: + type: string + format: date-time + TradeExecuted: + type: object + required: + - type + - symbol + - quantity + - price + - timestamp + properties: + type: + type: string + const: trade_executed + symbol: + type: string + quantity: + type: integer + price: + type: number + timestamp: + type: string + format: date-time + MarketAlert: + type: object + required: + - type + - severity + - message + properties: + type: + type: string + const: market_alert + severity: + type: string + enum: + - info + - warning + - critical + message: + type: string + ErrorResponse: + type: object + required: + - code + - message + properties: + code: + type: string + message: + type: string