@blimu/codegen 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -0
- package/dist/generator/typescript/templates/README.md.hbs +123 -0
- package/dist/generator/typescript/templates/client.ts.hbs +380 -0
- package/dist/generator/typescript/templates/index.ts.hbs +67 -0
- package/dist/generator/typescript/templates/package.json.hbs +48 -0
- package/dist/generator/typescript/templates/schema.ts.hbs +76 -0
- package/dist/generator/typescript/templates/schema.zod.ts.hbs +126 -0
- package/dist/generator/typescript/templates/service.ts.hbs +107 -0
- package/dist/generator/typescript/templates/tsconfig.json.hbs +28 -0
- package/dist/generator/typescript/templates/utils.ts.hbs +168 -0
- package/dist/index.d.mts +309 -0
- package/dist/index.d.ts +309 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +7 -0
- package/dist/index.mjs.map +1 -0
- package/dist/main.js +8 -0
- package/dist/main.js.map +1 -0
- package/package.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# @blimu/codegen
|
|
2
|
+
|
|
3
|
+
A powerful TypeScript library and CLI tool for generating type-safe SDKs from OpenAPI specifications. Built with NestJS Commander and following NestJS conventions.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🚀 **Multiple Language Support**: TypeScript, Go, Python (with more planned)
|
|
8
|
+
- 📝 **OpenAPI 3.x Support**: Full support for modern OpenAPI specifications
|
|
9
|
+
- 🎯 **Tag Filtering**: Include/exclude specific API endpoints by tags
|
|
10
|
+
- 🔧 **Highly Configurable**: Flexible configuration via MJS config files with TypeScript hints
|
|
11
|
+
- 📦 **Library & CLI**: Use as a TypeScript library or standalone CLI tool
|
|
12
|
+
- 🎨 **Beautiful Generated Code**: Clean, idiomatic code with excellent TypeScript types
|
|
13
|
+
- ⚡ **Function-Based Transforms**: Use JavaScript functions for operationId transformation with full type safety
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
yarn add @blimu/codegen
|
|
19
|
+
# or
|
|
20
|
+
npm install @blimu/codegen
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## CLI Usage
|
|
24
|
+
|
|
25
|
+
### Using Command Line Arguments
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Generate TypeScript SDK from OpenAPI spec
|
|
29
|
+
codegen generate \
|
|
30
|
+
--input https://petstore3.swagger.io/api/v3/openapi.json \
|
|
31
|
+
--type typescript \
|
|
32
|
+
--out ./petstore-sdk \
|
|
33
|
+
--package-name petstore-client \
|
|
34
|
+
--client-name PetStoreClient
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Using Configuration File
|
|
38
|
+
|
|
39
|
+
The CLI automatically looks for `chunkflow-codegen.config.mjs` in the current directory and parent directories. You can also specify a custom path:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Auto-discover chunkflow-codegen.config.mjs
|
|
43
|
+
codegen generate
|
|
44
|
+
|
|
45
|
+
# Use explicit config file path
|
|
46
|
+
codegen generate --config ./chunkflow-codegen.config.mjs
|
|
47
|
+
|
|
48
|
+
# Generate only a specific client from config
|
|
49
|
+
codegen generate --client MyClient
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Configuration File Example
|
|
53
|
+
|
|
54
|
+
Create `chunkflow-codegen.config.mjs` in your project root:
|
|
55
|
+
|
|
56
|
+
```javascript
|
|
57
|
+
// @ts-check
|
|
58
|
+
import { defineConfig } from "@blimu/codegen";
|
|
59
|
+
|
|
60
|
+
export default defineConfig({
|
|
61
|
+
spec: "http://localhost:3020/docs/backend-api/json",
|
|
62
|
+
clients: [
|
|
63
|
+
{
|
|
64
|
+
type: "typescript",
|
|
65
|
+
outDir: "./my-sdk",
|
|
66
|
+
packageName: "my-sdk",
|
|
67
|
+
name: "MyClient",
|
|
68
|
+
operationIdParser: (operationId, method, path) => {
|
|
69
|
+
// Custom transform logic
|
|
70
|
+
return operationId.replace(/Controller/g, "");
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The `// @ts-check` directive enables TypeScript type checking and autocomplete in your config file!
|
|
78
|
+
|
|
79
|
+
See `examples/chunkflow-codegen.config.mjs.example` for a complete example with all available options.
|
|
80
|
+
|
|
81
|
+
## Programmatic API
|
|
82
|
+
|
|
83
|
+
Use the codegen library programmatically in your TypeScript code:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import { generate, loadConfig, defineConfig } from "@blimu/codegen";
|
|
87
|
+
|
|
88
|
+
// Generate from config object
|
|
89
|
+
await generate({
|
|
90
|
+
spec: "http://localhost:3020/docs/backend-api/json",
|
|
91
|
+
clients: [
|
|
92
|
+
{
|
|
93
|
+
type: "typescript",
|
|
94
|
+
outDir: "./my-sdk",
|
|
95
|
+
packageName: "my-sdk",
|
|
96
|
+
name: "MyClient",
|
|
97
|
+
operationIdParser: (operationId, method, path) => {
|
|
98
|
+
return operationId.replace(/Controller/g, "");
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Generate from config file path
|
|
105
|
+
await generate("./chunkflow-codegen.config.mjs");
|
|
106
|
+
|
|
107
|
+
// Generate only a specific client
|
|
108
|
+
await generate("./chunkflow-codegen.config.mjs", { client: "MyClient" });
|
|
109
|
+
|
|
110
|
+
// Load config programmatically
|
|
111
|
+
const config = await loadConfig("./chunkflow-codegen.config.mjs");
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Configuration Options
|
|
115
|
+
|
|
116
|
+
### Top-Level Config
|
|
117
|
+
|
|
118
|
+
- `spec` (required): OpenAPI spec file path or URL
|
|
119
|
+
- `name` (optional): Name for this configuration
|
|
120
|
+
- `clients` (required): Array of client configurations
|
|
121
|
+
|
|
122
|
+
### Client Configuration
|
|
123
|
+
|
|
124
|
+
- `type` (required): Generator type (e.g., `'typescript'`)
|
|
125
|
+
- `outDir` (required): Output directory for generated SDK
|
|
126
|
+
- `packageName` (required): Package name for the generated SDK
|
|
127
|
+
- `name` (required): Client class name
|
|
128
|
+
- `moduleName` (optional): Module name for type augmentation generators
|
|
129
|
+
- `includeTags` (optional): Array of regex patterns for tags to include
|
|
130
|
+
- `excludeTags` (optional): Array of regex patterns for tags to exclude
|
|
131
|
+
- `includeQueryKeys` (optional): Generate query key helper methods
|
|
132
|
+
- `operationIdParser` (optional): Function to transform operationId to method name
|
|
133
|
+
- Signature: `(operationId: string, method: string, path: string) => string | Promise<string>`
|
|
134
|
+
- `preCommand` (optional): Commands to run before SDK generation
|
|
135
|
+
- `postCommand` (optional): Commands to run after SDK generation
|
|
136
|
+
- `defaultBaseURL` (optional): Default base URL for the client
|
|
137
|
+
- `exclude` (optional): Array of file paths to exclude from generation
|
|
138
|
+
- `typeAugmentation` (optional): Options for type augmentation generators
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# {{Client.name}} TypeScript SDK
|
|
2
|
+
|
|
3
|
+
This is an auto-generated TypeScript/JavaScript SDK for the {{Client.name}} API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install {{Client.packageName}}
|
|
9
|
+
# or
|
|
10
|
+
yarn add {{Client.packageName}}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { {{pascal Client.name}}Client } from '{{Client.packageName}}';
|
|
17
|
+
|
|
18
|
+
// Create a new client
|
|
19
|
+
const client = new {{pascal Client.name}}Client({
|
|
20
|
+
baseURL: '{{Client.defaultBaseURL}}',
|
|
21
|
+
timeoutMs: 10000,
|
|
22
|
+
retry: { retries: 2, backoffMs: 300, retryOn: [429, 500, 502, 503, 504] },
|
|
23
|
+
// Environment-based baseURL (optional)
|
|
24
|
+
env: 'sandbox',
|
|
25
|
+
envBaseURLs: { sandbox: 'https://api-sandbox.example.com', production: 'https://api.example.com' },
|
|
26
|
+
// Auth (generic API Key or Bearer header)
|
|
27
|
+
accessToken: process.env.API_TOKEN,
|
|
28
|
+
headerName: 'access_token', // or 'Authorization' (defaults to Authorization: Bearer <token>)
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
{{#each IR.services}}
|
|
32
|
+
{{#if (gt (len this.operations) 0)}}
|
|
33
|
+
{{#with (index this.operations 0) as |firstOp|}}
|
|
34
|
+
// Example: {{firstOp.summary}}
|
|
35
|
+
try {
|
|
36
|
+
const result = await client.{{serviceProp ../tag}}.{{methodName firstOp}}(
|
|
37
|
+
{{#each (pathParamsInOrder firstOp)}}{{#if @index}}, {{/if}}'{{this.name}}'{{/each}}
|
|
38
|
+
{{#if (gt (len firstOp.queryParams) 0)}}{{#if (gt (len (pathParamsInOrder firstOp)) 0)}}, {{/if}}{
|
|
39
|
+
{{#each firstOp.queryParams}}
|
|
40
|
+
{{#if this.required}}
|
|
41
|
+
{{this.name}}: {{#if (eq this.schema.kind "string")}}'example'{{else if (eq this.schema.kind "integer")}}123{{else if (eq this.schema.kind "boolean")}}true{{else}}undefined{{/if}},
|
|
42
|
+
{{/if}}
|
|
43
|
+
{{/each}}
|
|
44
|
+
}{{/if}}
|
|
45
|
+
{{#if firstOp.requestBody}}{{#if (or (gt (len (pathParamsInOrder firstOp)) 0) (gt (len firstOp.queryParams) 0))}}, {{/if}}{
|
|
46
|
+
// Request body data
|
|
47
|
+
}{{/if}}
|
|
48
|
+
);
|
|
49
|
+
console.log('Result:', result);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// FetchError with structured data
|
|
52
|
+
console.error(error);
|
|
53
|
+
}
|
|
54
|
+
{{/with}}
|
|
55
|
+
{{/if}}
|
|
56
|
+
{{/each}}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## TypeScript Support
|
|
60
|
+
|
|
61
|
+
This SDK is written in TypeScript and provides full type safety:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { {{pascal Client.name}}Client, Schema } from '{{Client.packageName}}';
|
|
65
|
+
|
|
66
|
+
const client = new {{pascal Client.name}}Client({ /* config */ });
|
|
67
|
+
|
|
68
|
+
// All methods are fully typed
|
|
69
|
+
// Schema types are available
|
|
70
|
+
{{#if IR.modelDefs}}
|
|
71
|
+
{{#with (index IR.modelDefs 0) as |firstModel|}}
|
|
72
|
+
const data: Schema.{{firstModel.name}} = {
|
|
73
|
+
// Fully typed object
|
|
74
|
+
};
|
|
75
|
+
{{/with}}
|
|
76
|
+
{{/if}}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Node.js Usage
|
|
80
|
+
|
|
81
|
+
For Node.js environments, you may need to provide a fetch implementation:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm install undici
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { fetch } from 'undici';
|
|
89
|
+
import { {{pascal Client.name}}Client } from '{{Client.packageName}}';
|
|
90
|
+
|
|
91
|
+
const client = new {{pascal Client.name}}Client({
|
|
92
|
+
baseURL: '{{Client.defaultBaseURL}}',
|
|
93
|
+
fetch,
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
{{#if IR.modelDefs}}
|
|
98
|
+
## Models and Types
|
|
99
|
+
|
|
100
|
+
The SDK includes the following TypeScript interfaces:
|
|
101
|
+
|
|
102
|
+
{{#each IR.modelDefs}}
|
|
103
|
+
- **{{this.name}}**{{#if this.annotations.description}}: {{this.annotations.description}}{{/if}}
|
|
104
|
+
{{/each}}
|
|
105
|
+
|
|
106
|
+
All types are available under the `Schema` namespace:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { Schema } from '{{Client.packageName}}';
|
|
110
|
+
|
|
111
|
+
// Use any model type
|
|
112
|
+
const user: Schema.User = { /* ... */ };
|
|
113
|
+
```
|
|
114
|
+
{{/if}}
|
|
115
|
+
|
|
116
|
+
## Contributing
|
|
117
|
+
|
|
118
|
+
This SDK is auto-generated. Please do not edit the generated files directly.
|
|
119
|
+
If you find issues, please report them in the main project repository.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
This SDK is generated from the {{Client.name}} API specification.
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
{{~#if IR.securitySchemes~}}
|
|
2
|
+
export type ClientOption = {
|
|
3
|
+
baseURL?: string;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
retry?: { retries: number; backoffMs: number; retryOn?: number[] };
|
|
7
|
+
onRequest?: (ctx: { url: string; init: RequestInit & { path: string; method: string; query?: Record<string, any>; headers: Headers }; attempt: number }) => void | Promise<void>;
|
|
8
|
+
onResponse?: (ctx: { url: string; init: RequestInit & { path: string; method: string; query?: Record<string, any>; headers: Headers }; attempt: number; response: Response }) => void | Promise<void>;
|
|
9
|
+
onError?: (err: unknown, ctx: { url: string; init: RequestInit & { path: string; method: string; query?: Record<string, any> }; attempt: number }) => void | Promise<void>;
|
|
10
|
+
// Environment & Auth
|
|
11
|
+
env?: 'sandbox' | 'production';
|
|
12
|
+
envBaseURLs?: { sandbox: string; production: string };
|
|
13
|
+
accessToken?: string | undefined | (() => string | undefined | Promise<string | undefined>);
|
|
14
|
+
headerName?: string;
|
|
15
|
+
{{~#each IR.securitySchemes~}}
|
|
16
|
+
{{~#if (eq this.type "http")~}}
|
|
17
|
+
{{~#if (eq this.scheme "bearer")~}}
|
|
18
|
+
{{camel this.key}}?: string;
|
|
19
|
+
{{~else if (eq this.scheme "basic")~}}
|
|
20
|
+
{{camel this.key}}?: { username: string; password: string };
|
|
21
|
+
{{~/if~}}
|
|
22
|
+
{{~else if (eq this.type "apiKey")~}}
|
|
23
|
+
{{camel this.key}}?: string;
|
|
24
|
+
{{~/if~}}
|
|
25
|
+
{{~/each~}}
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
credentials?: RequestCredentials;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class FetchError<T = unknown> extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
readonly status: number,
|
|
34
|
+
readonly data?: T,
|
|
35
|
+
readonly headers?: Headers,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "FetchError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class CoreClient {
|
|
43
|
+
constructor(private cfg: ClientOption = {}) {
|
|
44
|
+
// Set default base URL if not provided
|
|
45
|
+
if (!this.cfg.baseURL) {
|
|
46
|
+
if (this.cfg.env && this.cfg.envBaseURLs) {
|
|
47
|
+
this.cfg.baseURL = this.cfg.env === 'production' ? this.cfg.envBaseURLs.production : this.cfg.envBaseURLs.sandbox;
|
|
48
|
+
} else {
|
|
49
|
+
this.cfg.baseURL = "{{Client.defaultBaseURL}}";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
setAccessToken(token: string | undefined | (() => string | undefined | Promise<string | undefined>)) {
|
|
54
|
+
this.cfg.accessToken = token;
|
|
55
|
+
}
|
|
56
|
+
async request(
|
|
57
|
+
init: RequestInit & {
|
|
58
|
+
path: string;
|
|
59
|
+
method: string;
|
|
60
|
+
query?: Record<string, any>;
|
|
61
|
+
}
|
|
62
|
+
) {
|
|
63
|
+
let normalizedPath = init.path || "";
|
|
64
|
+
if (normalizedPath.length > 1 && normalizedPath.endsWith('/')) {
|
|
65
|
+
normalizedPath = normalizedPath.slice(0, -1);
|
|
66
|
+
}
|
|
67
|
+
const url = new URL((this.cfg.baseURL || "") + normalizedPath);
|
|
68
|
+
if (init.query) {
|
|
69
|
+
Object.entries(init.query).forEach(([k, v]) => {
|
|
70
|
+
if (v === undefined || v === null) return;
|
|
71
|
+
if (Array.isArray(v))
|
|
72
|
+
v.forEach((vv) => url.searchParams.append(k, String(vv)));
|
|
73
|
+
else url.searchParams.set(k, String(v));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
{{~#each IR.securitySchemes~}}
|
|
77
|
+
{{~#if (and (eq this.type "apiKey") (eq this.in "query"))~}}
|
|
78
|
+
if (this.cfg.{{camel this.key}}) {
|
|
79
|
+
url.searchParams.set("{{this.name}}", String(this.cfg.{{camel this.key}}));
|
|
80
|
+
}
|
|
81
|
+
{{~/if~}}
|
|
82
|
+
{{~/each~}}
|
|
83
|
+
const headers = new Headers({
|
|
84
|
+
...(this.cfg.headers || {}),
|
|
85
|
+
...(init.headers as any),
|
|
86
|
+
});
|
|
87
|
+
// Generic access token support (optional)
|
|
88
|
+
if (this.cfg.accessToken) {
|
|
89
|
+
const token = typeof this.cfg.accessToken === 'function' ? await this.cfg.accessToken() : this.cfg.accessToken;
|
|
90
|
+
// Only set header if token is not nullish
|
|
91
|
+
if (token != null) {
|
|
92
|
+
const name = this.cfg.headerName || 'Authorization';
|
|
93
|
+
if (name.toLowerCase() === 'authorization') headers.set(name, `Bearer ${String(token)}`);
|
|
94
|
+
else headers.set(name, String(token));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
{{~#each IR.securitySchemes~}}
|
|
98
|
+
{{~#if (eq this.type "http")~}}
|
|
99
|
+
{{~#if (eq this.scheme "bearer")~}}
|
|
100
|
+
const {{camel this.key}}Key = "{{camel this.key}}";
|
|
101
|
+
if (this.cfg[{{camel this.key}}Key])
|
|
102
|
+
headers.set("Authorization", `Bearer ${this.cfg[{{camel this.key}}Key]}`);
|
|
103
|
+
{{~else if (eq this.scheme "basic")~}}
|
|
104
|
+
const {{camel this.key}}Key = "{{camel this.key}}";
|
|
105
|
+
if (this.cfg[{{camel this.key}}Key]) {
|
|
106
|
+
const u = this.cfg[{{camel this.key}}Key].username;
|
|
107
|
+
const p = this.cfg[{{camel this.key}}Key].password;
|
|
108
|
+
const encoded = typeof btoa !== 'undefined' ? btoa(`${u}:${p}`) : (typeof Buffer !== 'undefined' ? Buffer.from(`${u}:${p}`).toString('base64') : '' );
|
|
109
|
+
if (encoded) headers.set("Authorization", `Basic ${encoded}`);
|
|
110
|
+
}
|
|
111
|
+
{{~/if~}}
|
|
112
|
+
{{~else if (eq this.type "apiKey")~}}
|
|
113
|
+
{{~#if (eq this.in "header")~}}
|
|
114
|
+
if (this.cfg?.{{camel this.key}})
|
|
115
|
+
headers.set("{{this.name}}", String(this.cfg?.{{camel this.key}}));
|
|
116
|
+
{{~else if (eq this.in "cookie")~}}
|
|
117
|
+
if (this.cfg?.{{camel this.key}})
|
|
118
|
+
headers.set("Cookie", `${"{{this.name}}"}=${String(this.cfg?.{{camel this.key}})}`);
|
|
119
|
+
{{~/if~}}
|
|
120
|
+
{{~/if~}}
|
|
121
|
+
{{~/each~}}
|
|
122
|
+
|
|
123
|
+
const doFetch = async (attempt: number) => {
|
|
124
|
+
// Clone init to prevent mutations from affecting concurrent requests
|
|
125
|
+
// Create a new Headers object for each request to avoid sharing references
|
|
126
|
+
const requestHeaders = new Headers(headers);
|
|
127
|
+
const fetchInit: RequestInit & {
|
|
128
|
+
path: string;
|
|
129
|
+
method: string;
|
|
130
|
+
query?: Record<string, any>;
|
|
131
|
+
headers: Headers;
|
|
132
|
+
} = {
|
|
133
|
+
...init,
|
|
134
|
+
headers: requestHeaders,
|
|
135
|
+
};
|
|
136
|
+
// Set credentials from config if provided (can be overridden by onRequest)
|
|
137
|
+
if (this.cfg.credentials !== undefined) {
|
|
138
|
+
fetchInit.credentials = this.cfg.credentials;
|
|
139
|
+
}
|
|
140
|
+
if (this.cfg.onRequest) await this.cfg.onRequest({ url: url.toString(), init: fetchInit, attempt });
|
|
141
|
+
let controller: AbortController | undefined;
|
|
142
|
+
let timeoutId: any;
|
|
143
|
+
const existingSignal = fetchInit.signal;
|
|
144
|
+
|
|
145
|
+
if (this.cfg.timeoutMs && typeof AbortController !== 'undefined') {
|
|
146
|
+
controller = new AbortController();
|
|
147
|
+
|
|
148
|
+
// If there's an existing signal, combine it with the timeout signal
|
|
149
|
+
// The combined controller will abort when either signal aborts
|
|
150
|
+
if (existingSignal) {
|
|
151
|
+
// If existing signal is already aborted, abort the new controller immediately
|
|
152
|
+
if (existingSignal.aborted) {
|
|
153
|
+
controller.abort();
|
|
154
|
+
} else {
|
|
155
|
+
// Listen to the existing signal and abort the combined controller when it aborts
|
|
156
|
+
existingSignal.addEventListener('abort', () => {
|
|
157
|
+
controller?.abort();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fetchInit.signal = controller.signal;
|
|
163
|
+
timeoutId = setTimeout(() => controller?.abort(), this.cfg.timeoutMs);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const res = await (this.cfg.fetch || fetch)(url.toString(), fetchInit);
|
|
167
|
+
if (this.cfg.onResponse) await this.cfg.onResponse({ url: url.toString(), init: fetchInit, attempt, response: res });
|
|
168
|
+
const ct = res.headers.get("content-type") || "";
|
|
169
|
+
let parsed: any;
|
|
170
|
+
if (ct.includes("application/json")) {
|
|
171
|
+
parsed = await res.json();
|
|
172
|
+
} else if (ct.startsWith("text/")) {
|
|
173
|
+
parsed = await res.text();
|
|
174
|
+
} else {
|
|
175
|
+
// binary or unknown -> ArrayBuffer
|
|
176
|
+
parsed = await res.arrayBuffer();
|
|
177
|
+
}
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
throw new FetchError(
|
|
180
|
+
parsed?.message || `HTTP ${res.status}`,
|
|
181
|
+
res.status,
|
|
182
|
+
parsed,
|
|
183
|
+
res.headers,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return parsed as any;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (this.cfg.onError) await this.cfg.onError(err, { url: url.toString(), init, attempt });
|
|
189
|
+
throw err;
|
|
190
|
+
} finally {
|
|
191
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const retries = this.cfg.retry?.retries ?? 0;
|
|
196
|
+
const baseBackoff = this.cfg.retry?.backoffMs ?? 300;
|
|
197
|
+
const retryOn = this.cfg.retry?.retryOn ?? [429, 500, 502, 503, 504];
|
|
198
|
+
|
|
199
|
+
let lastError: unknown;
|
|
200
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
201
|
+
try {
|
|
202
|
+
return await doFetch(attempt);
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
// Retry on network errors or configured status errors
|
|
205
|
+
const status = err?.status as number | undefined;
|
|
206
|
+
const shouldRetry = status ? retryOn.includes(status) : true;
|
|
207
|
+
if (attempt < retries && shouldRetry) {
|
|
208
|
+
const delay = baseBackoff * Math.pow(2, attempt);
|
|
209
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
210
|
+
lastError = err;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (err instanceof DOMException) throw err;
|
|
214
|
+
if (err instanceof FetchError) throw err;
|
|
215
|
+
if (typeof err === 'string') throw new FetchError(err, status ?? 0);
|
|
216
|
+
throw new FetchError((err as Error)?.message || 'Network error', status ?? 0);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
throw lastError as any;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async *requestStream<T = any>(
|
|
223
|
+
init: RequestInit & {
|
|
224
|
+
path: string;
|
|
225
|
+
method: string;
|
|
226
|
+
query?: Record<string, any>;
|
|
227
|
+
contentType: string;
|
|
228
|
+
streamingFormat?: "sse" | "ndjson" | "chunked";
|
|
229
|
+
}
|
|
230
|
+
): AsyncGenerator<T, void, unknown> {
|
|
231
|
+
let normalizedPath = init.path || "";
|
|
232
|
+
if (normalizedPath.length > 1 && normalizedPath.endsWith('/')) {
|
|
233
|
+
normalizedPath = normalizedPath.slice(0, -1);
|
|
234
|
+
}
|
|
235
|
+
const url = new URL((this.cfg.baseURL || "") + normalizedPath);
|
|
236
|
+
if (init.query) {
|
|
237
|
+
Object.entries(init.query).forEach(([k, v]) => {
|
|
238
|
+
if (v === undefined || v === null) return;
|
|
239
|
+
if (Array.isArray(v))
|
|
240
|
+
v.forEach((vv) => url.searchParams.append(k, String(vv)));
|
|
241
|
+
else url.searchParams.set(k, String(v));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
{{~#each IR.securitySchemes~}}
|
|
245
|
+
{{~#if (and (eq this.type "apiKey") (eq this.in "query"))~}}
|
|
246
|
+
if (this.cfg.{{camel this.key}}) {
|
|
247
|
+
url.searchParams.set("{{this.name}}", String(this.cfg.{{camel this.key}}));
|
|
248
|
+
}
|
|
249
|
+
{{~/if~}}
|
|
250
|
+
{{~/each~}}
|
|
251
|
+
const headers = new Headers({
|
|
252
|
+
...(this.cfg.headers || {}),
|
|
253
|
+
...(init.headers as any),
|
|
254
|
+
});
|
|
255
|
+
// Generic access token support (optional)
|
|
256
|
+
if (this.cfg.accessToken) {
|
|
257
|
+
const token = typeof this.cfg.accessToken === 'function' ? await this.cfg.accessToken() : this.cfg.accessToken;
|
|
258
|
+
// Only set header if token is not nullish
|
|
259
|
+
if (token != null) {
|
|
260
|
+
const name = this.cfg.headerName || 'Authorization';
|
|
261
|
+
if (name.toLowerCase() === 'authorization') headers.set(name, `Bearer ${String(token)}`);
|
|
262
|
+
else headers.set(name, String(token));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
{{~#each IR.securitySchemes~}}
|
|
266
|
+
{{~#if (eq this.type "http")~}}
|
|
267
|
+
{{~#if (eq this.scheme "bearer")~}}
|
|
268
|
+
const {{camel this.key}}Key = "{{camel this.key}}";
|
|
269
|
+
if (this.cfg[{{camel this.key}}Key])
|
|
270
|
+
headers.set("Authorization", `Bearer ${this.cfg[{{camel this.key}}Key]}`);
|
|
271
|
+
{{~else if (eq this.scheme "basic")~}}
|
|
272
|
+
const {{camel this.key}}Key = "{{camel this.key}}";
|
|
273
|
+
if (this.cfg[{{camel this.key}}Key]) {
|
|
274
|
+
const u = this.cfg[{{camel this.key}}Key].username;
|
|
275
|
+
const p = this.cfg[{{camel this.key}}Key].password;
|
|
276
|
+
const encoded = typeof btoa !== 'undefined' ? btoa(`${u}:${p}`) : (typeof Buffer !== 'undefined' ? Buffer.from(`${u}:${p}`).toString('base64') : '' );
|
|
277
|
+
if (encoded) headers.set("Authorization", `Basic ${encoded}`);
|
|
278
|
+
}
|
|
279
|
+
{{~/if~}}
|
|
280
|
+
{{~else if (eq this.type "apiKey")~}}
|
|
281
|
+
{{~#if (eq this.in "header")~}}
|
|
282
|
+
if (this.cfg?.{{camel this.key}})
|
|
283
|
+
headers.set("{{this.name}}", String(this.cfg?.{{camel this.key}}));
|
|
284
|
+
{{~else if (eq this.in "cookie")~}}
|
|
285
|
+
if (this.cfg?.{{camel this.key}})
|
|
286
|
+
headers.set("Cookie", `${"{{this.name}}"}=${String(this.cfg?.{{camel this.key}})}`);
|
|
287
|
+
{{~/if~}}
|
|
288
|
+
{{~/if~}}
|
|
289
|
+
{{~/each~}}
|
|
290
|
+
|
|
291
|
+
const fetchInit: RequestInit & {
|
|
292
|
+
path: string;
|
|
293
|
+
method: string;
|
|
294
|
+
query?: Record<string, any>;
|
|
295
|
+
headers: Headers;
|
|
296
|
+
} = {
|
|
297
|
+
...init,
|
|
298
|
+
headers,
|
|
299
|
+
};
|
|
300
|
+
// Set credentials from config if provided
|
|
301
|
+
if (this.cfg.credentials !== undefined) {
|
|
302
|
+
fetchInit.credentials = this.cfg.credentials;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (this.cfg.onRequest) await this.cfg.onRequest({ url: url.toString(), init: fetchInit, attempt: 0 });
|
|
306
|
+
|
|
307
|
+
let controller: AbortController | undefined;
|
|
308
|
+
let timeoutId: any;
|
|
309
|
+
const existingSignal = fetchInit.signal;
|
|
310
|
+
|
|
311
|
+
if (this.cfg.timeoutMs && typeof AbortController !== 'undefined') {
|
|
312
|
+
controller = new AbortController();
|
|
313
|
+
if (existingSignal) {
|
|
314
|
+
if (existingSignal.aborted) {
|
|
315
|
+
controller.abort();
|
|
316
|
+
} else {
|
|
317
|
+
existingSignal.addEventListener('abort', () => {
|
|
318
|
+
controller?.abort();
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
fetchInit.signal = controller.signal;
|
|
323
|
+
timeoutId = setTimeout(() => controller?.abort(), this.cfg.timeoutMs);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const res = await (this.cfg.fetch || fetch)(url.toString(), fetchInit);
|
|
328
|
+
if (this.cfg.onResponse) await this.cfg.onResponse({ url: url.toString(), init: fetchInit, attempt: 0, response: res });
|
|
329
|
+
|
|
330
|
+
if (!res.ok) {
|
|
331
|
+
const ct = res.headers.get("content-type") || "";
|
|
332
|
+
let parsed: any;
|
|
333
|
+
if (ct.includes("application/json")) {
|
|
334
|
+
parsed = await res.json();
|
|
335
|
+
} else if (ct.startsWith("text/")) {
|
|
336
|
+
parsed = await res.text();
|
|
337
|
+
} else {
|
|
338
|
+
parsed = await res.arrayBuffer();
|
|
339
|
+
}
|
|
340
|
+
throw new FetchError(
|
|
341
|
+
parsed?.message || `HTTP ${res.status}`,
|
|
342
|
+
res.status,
|
|
343
|
+
parsed,
|
|
344
|
+
res.headers,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Import streaming parsers
|
|
349
|
+
const { parseSSEStream, parseNDJSONStream } = await import("./utils");
|
|
350
|
+
|
|
351
|
+
// Route to appropriate parser based on streaming format
|
|
352
|
+
if (init.streamingFormat === "sse") {
|
|
353
|
+
yield* parseSSEStream(res) as AsyncGenerator<T, void, unknown>;
|
|
354
|
+
} else if (init.streamingFormat === "ndjson") {
|
|
355
|
+
yield* parseNDJSONStream<T>(res);
|
|
356
|
+
} else {
|
|
357
|
+
// Generic chunked streaming - yield raw chunks as strings
|
|
358
|
+
if (!res.body) return;
|
|
359
|
+
const reader = res.body.getReader();
|
|
360
|
+
const decoder = new TextDecoder();
|
|
361
|
+
try {
|
|
362
|
+
while (true) {
|
|
363
|
+
const { done, value } = await reader.read();
|
|
364
|
+
if (done) break;
|
|
365
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
366
|
+
yield chunk as T;
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
reader.releaseLock();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (this.cfg.onError) await this.cfg.onError(err, { url: url.toString(), init, attempt: 0 });
|
|
374
|
+
throw err;
|
|
375
|
+
} finally {
|
|
376
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
{{~/if~}}
|