create-swagger-client 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/README.md +21 -13
  2. package/index.mjs +202 -155
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # create-swagger-client
1
+ # Create Swagger Client
2
2
 
3
3
  A TypeScript tool that generates a fully type-safe REST API client from OpenAPI/Swagger specifications. Built with `openapi-typescript` and `ts-morph`, it creates a strongly-typed client class with autocomplete and compile-time type checking for all API endpoints.
4
4
 
@@ -10,6 +10,7 @@ A TypeScript tool that generates a fully type-safe REST API client from OpenAPI/
10
10
  - 📝 **OpenAPI Spec Support**: Works with OpenAPI 3.x specifications (JSON or YAML)
11
11
  - 🌐 **URL or File Input**: Generate from remote URLs or local files
12
12
  - 🎯 **Type Inference**: Automatic extraction of path params, query params, headers, and request/response types
13
+ - ⏱️ **Built-in Timeout**: Default 30s timeout per request (configurable)
13
14
 
14
15
  ## Installation
15
16
 
@@ -38,7 +39,7 @@ npx create-swagger-client https://api.example.com/openapi.json my-api-client.ts
38
39
 
39
40
  **Arguments:**
40
41
  - `source` (required): URL or file path to your OpenAPI/Swagger specification
41
- - `output` (optional): Output file name (default: `client-api.ts`)
42
+ - `output` (optional): Output file name (default: `swagger-client.ts`)
42
43
 
43
44
  This will generate a file with:
44
45
  - All TypeScript types from your OpenAPI spec
@@ -50,7 +51,7 @@ This will generate a file with:
50
51
  After generation, import and use the client:
51
52
 
52
53
  ```typescript
53
- import { RestApiClient } from './client-api';
54
+ import { RestApiClient } from './swagger-client';
54
55
 
55
56
  // Initialize the client
56
57
  const api = new RestApiClient('https://api.example.com', {
@@ -129,23 +130,30 @@ const result = await api.post('/projects/{projectId}/tasks', {
129
130
 
130
131
  #### Custom Fetch Options
131
132
 
133
+ Each method accepts an optional `RequestInit` as the third argument:
134
+
132
135
  ```typescript
133
- const api = new RestApiClient('https://api.example.com', {
134
- headers: {
135
- 'Authorization': 'Bearer TOKEN'
136
- },
137
- mode: 'cors',
136
+ const users = await api.get('/users', { query: { page: 1 } }, {
138
137
  credentials: 'include',
139
- // Any other RequestInit options
138
+ mode: 'cors'
140
139
  });
141
140
  ```
142
141
 
142
+ #### Timeout
143
+
144
+ The client uses a default timeout of 30 seconds. You can override it in the constructor:
145
+
146
+ ```typescript
147
+ const api = new RestApiClient('https://api.example.com', {}, 10_000);
148
+ ```
149
+
143
150
  ## Generated Types
144
151
 
145
152
  The generator creates several useful type utilities:
146
153
 
147
154
  - `RestMethod`: Union of HTTP methods (`"get" | "post" | "put" | "delete" | "patch"`)
148
155
  - `KeyPaths`: All available API paths
156
+ - `PathsForMethod<M>`: Paths that support method `M`
149
157
  - `ExtractPathParams<Path, Method>`: Extract path parameters for an endpoint
150
158
  - `ExtractQueryParams<Path, Method>`: Extract query parameters for an endpoint
151
159
  - `ExtractHeaderParams<Path, Method>`: Extract header parameters for an endpoint
@@ -161,7 +169,7 @@ import {
161
169
  RestApiClient,
162
170
  ExtractBody,
163
171
  APIResponse
164
- } from './client-api';
172
+ } from './swagger-client';
165
173
 
166
174
  // Use types in your code
167
175
  type CreateUserBody = ExtractBody<'/users', 'post'>;
@@ -174,7 +182,7 @@ const createUser = (data: CreateUserBody): Promise<UserResponse> => {
174
182
 
175
183
  ## Error Handling
176
184
 
177
- The client throws errors for failed requests:
185
+ The client throws an `ApiError` for failed requests. The error includes `status`, `statusText`, and a parsed JSON `body` when available:
178
186
 
179
187
  ```typescript
180
188
  try {
@@ -183,7 +191,7 @@ try {
183
191
  });
184
192
  } catch (error) {
185
193
  console.error('API request failed:', error.message);
186
- // Error message includes status code, status text, and response body
194
+ // error.status, error.statusText, error.body
187
195
  }
188
196
  ```
189
197
 
@@ -198,4 +206,4 @@ MIT
198
206
 
199
207
  ## Contributing
200
208
 
201
- Contributions are welcome! Please feel free to submit a Pull Request.
209
+ Contributions are welcome! Please feel free to submit a Pull Request.
package/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from "fs";
2
3
  import openapiTS, { astToString } from "openapi-typescript";
3
4
  import ora from "ora";
4
5
  import { resolve } from "path";
@@ -24,6 +25,11 @@ async function generate() {
24
25
  if (isUrl(source) === false) {
25
26
  source = resolve(process.cwd(), source);
26
27
  }
28
+
29
+ if (existsSync(source)) {
30
+ source = readFileSync(source, "utf-8");
31
+ }
32
+
27
33
  const spinner = ora(`Generating API client from ${source}...`).start();
28
34
  const ast = await openapiTS(source);
29
35
  const contents = astToString(ast);
@@ -78,45 +84,31 @@ async function generate() {
78
84
  isExported: true,
79
85
  typeParameters: ["K extends KeyPaths", "M extends RestMethod"],
80
86
  type: `paths[K][M] extends { responses: infer R }
81
- ? R extends {
82
- content: { "application/json": infer C };
83
- }
87
+ ? R extends { "200"?: { content: { "application/json": infer C } } }
84
88
  ? C
85
- : R extends {
86
- [status: number]: infer S;
87
- }
88
- ? S extends { content: { "application/json": infer C } }
89
- ? C
90
- : never
91
- : never
92
- : never;`,
89
+ : R extends { content: { "application/json": infer C } }
90
+ ? C
91
+ : R extends Record<number | string, { content: { "application/json": infer C } }>
92
+ ? C
93
+ : never
94
+ : never`,
93
95
  });
94
96
  sourceFile.addTypeAlias({
95
97
  name: "ApiPayload",
96
98
  isExported: true,
97
- typeParameters: ["T extends KeyPaths", "K extends RestMethod"],
99
+ typeParameters: ["T extends KeyPaths", "M extends RestMethod"],
98
100
  type: `{
99
- path?: ExtractPathParams<T, K>;
100
- query?: ExtractQueryParams<T, K>;
101
- body?: K extends "post" | "put" | "patch" ? ExtractBody<T, K> : never;
102
- headers?: ExtractHeaderParams<T, K>;
101
+ path?: ExtractPathParams<T, M>;
102
+ query?: ExtractQueryParams<T, M>;
103
+ body?: M extends "post" | "put" | "patch" ? ExtractBody<T, M> : never;
104
+ headers?: ExtractHeaderParams<T, M> & Record<string, string>;
103
105
  }`,
104
106
  });
105
107
  sourceFile.addTypeAlias({
106
- name: "ApiClientType",
107
- isExported: true,
108
- type: `{
109
- [K in RestMethod]: <T extends KeyPaths>(
110
- path: T,
111
- payload?: ApiPayload<T, K>,
112
- ) => Promise<APIResponse<T, K>>;
113
- }`,
114
- });
115
- sourceFile.addTypeAlias({
116
- name: "TypePaths",
117
- typeParameters: ["T extends RestMethod"],
108
+ name: "PathsForMethod",
109
+ typeParameters: ["M extends RestMethod"],
118
110
  type: `{
119
- [K in KeyPaths]: paths[K] extends { [M in T]: unknown } ? K : never;
111
+ [K in KeyPaths]: paths[K] extends Record<M, unknown> ? K : never;
120
112
  }[KeyPaths]`,
121
113
  });
122
114
  sourceFile.addClass({
@@ -125,195 +117,250 @@ async function generate() {
125
117
  ctors: [
126
118
  {
127
119
  parameters: [
128
- { name: "basePath", type: "string", scope: tsMorph.Scope.Private },
120
+ { name: "baseUrl", type: "string", scope: tsMorph.Scope.Private },
129
121
  {
130
- name: "option",
122
+ name: "defaultOptions",
131
123
  type: "RequestInit",
132
- hasQuestionToken: true,
124
+ initializer: "{}",
125
+ scope: tsMorph.Scope.Private,
126
+ },
127
+ {
128
+ name: "defaultTimeoutMs",
129
+ type: "number",
130
+ initializer: "30000",
133
131
  scope: tsMorph.Scope.Private,
134
132
  },
135
133
  ],
134
+ statements: `this.baseUrl = baseUrl.replace(/\\/+$/, "");`,
136
135
  },
137
136
  ],
138
137
  methods: [
139
138
  {
140
- name: "fetcher",
141
- scope: tsMorph.Scope.Public,
139
+ name: "buildUrl",
140
+ scope: tsMorph.Scope.Private,
141
+ typeParameters: ["P extends KeyPaths", "M extends RestMethod"],
142
+ parameters: [
143
+ { name: "pathTemplate", type: "P" },
144
+ { name: "pathParams", type: "ExtractPathParams<P, M>", hasQuestionToken: true },
145
+ ],
146
+ returnType: "URL",
147
+ statements: `let pathname = pathTemplate as string;
148
+
149
+ if (pathParams) {
150
+ pathname = pathname.replace(/\\{([^}]+)\\}/g, (_, paramName) => {
151
+ const value = (pathParams as Record<string, unknown>)[paramName];
152
+ if (value === undefined || value === null) {
153
+ throw new Error(\`Missing required path parameter: \${paramName}\`);
154
+ }
155
+ return encodeURIComponent(String(value));
156
+ });
157
+ }
158
+
159
+ return new URL(this.baseUrl + pathname);`,
160
+ },
161
+ {
162
+ name: "appendQueryParams",
163
+ scope: tsMorph.Scope.Private,
164
+ parameters: [
165
+ { name: "url", type: "URL" },
166
+ { name: "query", type: "Record<string, unknown>", hasQuestionToken: true },
167
+ ],
168
+ returnType: "void",
169
+ statements: `if (!query) return;
170
+
171
+ for (const [key, value] of Object.entries(query)) {
172
+ if (value === undefined || value === null) continue;
173
+
174
+ if (Array.isArray(value)) {
175
+ value.forEach((v) => {
176
+ if (v !== undefined && v !== null) {
177
+ url.searchParams.append(key, String(v));
178
+ }
179
+ });
180
+ } else {
181
+ url.searchParams.append(key, String(value));
182
+ }
183
+ }`,
184
+ },
185
+ {
186
+ name: "prepareBody",
187
+ scope: tsMorph.Scope.Private,
188
+ typeParameters: ["M extends RestMethod"],
189
+ parameters: [
190
+ { name: "method", type: "M" },
191
+ { name: "body", type: "unknown", hasQuestionToken: true },
192
+ ],
193
+ returnType: "BodyInit | null",
194
+ statements: `if (!body || !["post", "put", "patch"].includes(method)) return null;
195
+ return JSON.stringify(body);`,
196
+ },
197
+ {
198
+ name: "fetchWithTimeout",
199
+ scope: tsMorph.Scope.Private,
142
200
  isAsync: true,
143
201
  parameters: [
144
202
  { name: "input", type: "RequestInfo" },
145
- { name: "init", type: "RequestInit", hasQuestionToken: true },
203
+ { name: "init", type: "RequestInit" },
204
+ { name: "timeoutMs", type: "number" },
146
205
  ],
147
- statements: `const headers = {
148
- "Content-Type": "application/json",
149
- ...init?.headers,
150
- };
206
+ returnType: "Promise<Response>",
207
+ statements: `const controller = new AbortController();
208
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
151
209
 
152
- const response = await fetch(input, { ...init, headers });
153
- if (!response.ok) {
154
- const errorBody = await response.text();
155
- throw new Error(
156
- \`API request failed: \${response.status} \${response.statusText} - \${errorBody}\`,
157
- );
158
- }
159
- return response.json();`,
210
+ try {
211
+ const response = await fetch(input, {
212
+ ...init,
213
+ signal: controller.signal,
214
+ });
215
+ clearTimeout(timeoutId);
216
+ return response;
217
+ } catch (err) {
218
+ clearTimeout(timeoutId);
219
+ if (err instanceof DOMException && err.name === "AbortError") {
220
+ throw new Error(\`Request timed out after \${timeoutMs}ms\`);
221
+ }
222
+ throw err;
223
+ }`,
224
+ },
225
+ {
226
+ name: "createError",
227
+ scope: tsMorph.Scope.Private,
228
+ parameters: [
229
+ { name: "response", type: "Response" },
230
+ { name: "body", type: "string | null" },
231
+ ],
232
+ statements: `return Object.assign(new Error(), {
233
+ name: "ApiError",
234
+ message: \`API request failed: \${response.status} \${response.statusText}\`,
235
+ status: response.status,
236
+ statusText: response.statusText,
237
+ body: body ? JSON.parse(body) : null,
238
+ });`,
160
239
  },
161
240
  {
162
241
  name: "request",
163
- typeParameters: ["M extends RestMethod", "P extends TypePaths<M>"],
242
+ isAsync: true,
243
+ typeParameters: ["M extends RestMethod", "P extends PathsForMethod<M>"],
164
244
  parameters: [
165
245
  { name: "method", type: "M" },
166
246
  { name: "path", type: "P" },
167
247
  {
168
- name: "init",
248
+ name: "payload",
169
249
  type: "ApiPayload<P, M>",
170
250
  initializer: "{} as ApiPayload<P, M>",
171
251
  },
252
+ {
253
+ name: "options",
254
+ type: "RequestInit",
255
+ initializer: "{}",
256
+ },
172
257
  ],
173
258
  returnType: "Promise<APIResponse<P, M>>",
174
- statements: `const url = new URL(this.basePath + String(path));
259
+ statements: `const url = this.buildUrl(path, payload.path);
260
+ this.appendQueryParams(url, payload.query ?? {});
175
261
 
176
- url.pathname = this.buildPathUrl(url.pathname, init.path);
177
- this.appendQueryParams(url, init.query);
262
+ const headers = {
263
+ "Content-Type": "application/json",
264
+ ...this.defaultOptions.headers,
265
+ ...payload.headers,
266
+ ...options.headers,
267
+ };
178
268
 
179
269
  const requestInit: RequestInit = {
180
270
  method: method.toUpperCase(),
181
- ...this.option,
182
- headers: {
183
- ...(this.option?.headers ?? {}),
184
- ...(init.headers ?? {}),
185
- },
186
- body: this.prepareBody(method, init.body),
271
+ ...this.defaultOptions,
272
+ ...options,
273
+ headers,
274
+ body: this.prepareBody(method, payload.body),
187
275
  };
188
276
 
189
- return this.fetcher(url.toString(), requestInit) as Promise<
190
- APIResponse<P, M>
191
- >;`,
277
+ const response = await this.fetchWithTimeout(
278
+ url.toString(),
279
+ requestInit,
280
+ this.defaultTimeoutMs,
281
+ );
282
+
283
+ let responseBody: unknown;
284
+
285
+ const contentType = response.headers.get("content-type") || "";
286
+ if (contentType.includes("application/json")) {
287
+ responseBody = await response.json();
288
+ } else {
289
+ responseBody = await response.text();
290
+ }
291
+
292
+ if (!response.ok) {
293
+ throw this.createError(
294
+ response,
295
+ typeof responseBody === "string" ? responseBody : null,
296
+ );
297
+ }
298
+
299
+ return responseBody as APIResponse<P, M>;`,
192
300
  },
193
301
  {
194
302
  name: "get",
195
303
  scope: tsMorph.Scope.Public,
196
- typeParameters: ['T extends TypePaths<"get">'],
304
+ typeParameters: ['P extends PathsForMethod<"get">'],
197
305
  parameters: [
198
- { name: "path", type: "T" },
199
- {
200
- name: "payload",
201
- type: 'ApiPayload<T, "get">',
202
- hasQuestionToken: true,
203
- },
306
+ { name: "path", type: "P" },
307
+ { name: "payload", type: 'ApiPayload<P, "get">', hasQuestionToken: true },
308
+ { name: "options", type: "RequestInit", hasQuestionToken: true },
204
309
  ],
205
- returnType: 'Promise<APIResponse<T, "get">>',
206
- statements: 'return this.request("get", path, payload);',
310
+ returnType: 'Promise<APIResponse<P, "get">>',
311
+ statements: 'return this.request("get", path, payload, options);',
207
312
  },
208
313
  {
209
314
  name: "post",
210
315
  scope: tsMorph.Scope.Public,
211
- typeParameters: ['T extends TypePaths<"post">'],
316
+ typeParameters: ['P extends PathsForMethod<"post">'],
212
317
  parameters: [
213
- { name: "path", type: "T" },
214
- {
215
- name: "payload",
216
- type: 'ApiPayload<T, "post">',
217
- hasQuestionToken: true,
218
- },
318
+ { name: "path", type: "P" },
319
+ { name: "payload", type: 'ApiPayload<P, "post">', hasQuestionToken: true },
320
+ { name: "options", type: "RequestInit", hasQuestionToken: true },
219
321
  ],
220
- returnType: 'Promise<APIResponse<T, "post">>',
221
- statements: 'return this.request("post", path, payload);',
322
+ returnType: 'Promise<APIResponse<P, "post">>',
323
+ statements: 'return this.request("post", path, payload, options);',
222
324
  },
223
325
  {
224
326
  name: "put",
225
327
  scope: tsMorph.Scope.Public,
226
- typeParameters: ['T extends TypePaths<"put">'],
227
- parameters: [
228
- { name: "path", type: "T" },
229
- {
230
- name: "payload",
231
- type: 'ApiPayload<T, "put">',
232
- hasQuestionToken: true,
233
- },
234
- ],
235
- returnType: 'Promise<APIResponse<T, "put">>',
236
- statements: 'return this.request("put", path, payload);',
237
- },
238
- {
239
- name: "delete",
240
- scope: tsMorph.Scope.Public,
241
- typeParameters: ['T extends TypePaths<"delete">'],
328
+ typeParameters: ['P extends PathsForMethod<"put">'],
242
329
  parameters: [
243
- { name: "path", type: "T" },
244
- {
245
- name: "payload",
246
- type: 'ApiPayload<T, "delete">',
247
- hasQuestionToken: true,
248
- },
330
+ { name: "path", type: "P" },
331
+ { name: "payload", type: 'ApiPayload<P, "put">', hasQuestionToken: true },
332
+ { name: "options", type: "RequestInit", hasQuestionToken: true },
249
333
  ],
250
- returnType: 'Promise<APIResponse<T, "delete">>',
251
- statements: 'return this.request("delete", path, payload);',
334
+ returnType: 'Promise<APIResponse<P, "put">>',
335
+ statements: 'return this.request("put", path, payload, options);',
252
336
  },
253
337
  {
254
338
  name: "patch",
255
339
  scope: tsMorph.Scope.Public,
256
- typeParameters: ['T extends TypePaths<"patch">'],
257
- parameters: [
258
- { name: "path", type: "T" },
259
- {
260
- name: "payload",
261
- type: 'ApiPayload<T, "patch">',
262
- hasQuestionToken: true,
263
- },
264
- ],
265
- returnType: 'Promise<APIResponse<T, "patch">>',
266
- statements: 'return this.request("patch", path, payload);',
267
- },
268
- {
269
- name: "buildPathUrl",
270
- scope: tsMorph.Scope.Private,
340
+ typeParameters: ['P extends PathsForMethod<"patch">'],
271
341
  parameters: [
272
- { name: "basePath", type: "string" },
273
- { name: "pathParams", type: "unknown", hasQuestionToken: true },
274
- ],
275
- returnType: "string",
276
- statements: `let pathname = basePath;
277
- if (pathParams != null) {
278
- const params = pathParams as Record<string, unknown>;
279
- pathname = decodeURIComponent(pathname).replace(/{(w+)}/g, (_, key) =>
280
- encodeURIComponent(String(params[key])),
281
- );
282
- }
283
- return pathname;`,
284
- },
285
- {
286
- name: "prepareBody",
287
- scope: tsMorph.Scope.Private,
288
- parameters: [
289
- { name: "method", type: "RestMethod" },
290
- { name: "body", type: "unknown", hasQuestionToken: true },
342
+ { name: "path", type: "P" },
343
+ { name: "payload", type: 'ApiPayload<P, "patch">', hasQuestionToken: true },
344
+ { name: "options", type: "RequestInit", hasQuestionToken: true },
291
345
  ],
292
- returnType: "string | undefined",
293
- statements: `if (body && ["post", "put", "patch"].includes(method)) {
294
- return JSON.stringify(body);
295
- }
296
- return undefined;`,
346
+ returnType: 'Promise<APIResponse<P, "patch">>',
347
+ statements: 'return this.request("patch", path, payload, options);',
297
348
  },
298
349
  {
299
- name: "appendQueryParams",
300
- scope: tsMorph.Scope.Private,
350
+ name: "delete",
351
+ scope: tsMorph.Scope.Public,
352
+ typeParameters: ['P extends PathsForMethod<"delete">'],
301
353
  parameters: [
302
- { name: "url", type: "URL" },
303
- { name: "queryParams", type: "unknown", hasQuestionToken: true },
354
+ { name: "path", type: "P" },
355
+ { name: "payload", type: 'ApiPayload<P, "delete">', hasQuestionToken: true },
356
+ { name: "options", type: "RequestInit", hasQuestionToken: true },
304
357
  ],
305
- returnType: "void",
306
- statements: `if (queryParams != null) {
307
- const params = queryParams as Record<string, unknown>;
308
- for (const [key, value] of Object.entries(params)) {
309
- if (value !== undefined && value !== null) {
310
- url.searchParams.append(key, String(value));
311
- }
312
- }
313
- }`,
358
+ returnType: 'Promise<APIResponse<P, "delete">>',
359
+ statements: 'return this.request("delete", path, payload, options);',
314
360
  },
315
361
  ],
316
362
  });
363
+
317
364
  await sourceFile.formatText();
318
365
  await project.save();
319
366
  spinner.stopAndPersist({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-swagger-client",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Generate fully type-safe REST API clients from OpenAPI/Swagger specifications",
5
5
  "main": "./index.mjs",
6
6
  "module": "./index.mjs",