@taico/openapi-sdkgen 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 +206 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +16 -0
- package/dist/generator.d.ts +2 -0
- package/dist/generator.js +546 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +24 -0
- package/dist/parser.d.ts +2 -0
- package/dist/parser.js +171 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +4 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# @taico/openapi-sdkgen
|
|
2
|
+
|
|
3
|
+
A TypeScript SDK generator from OpenAPI specs with focus on developer ergonomics and maintainability.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @taico/openapi-sdkgen
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
openapi-sdkgen ./openapi.json ./generated
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Motivation
|
|
18
|
+
|
|
19
|
+
The existing `@taico/client` package uses `openapi-typescript-codegen` with multiple post-processing scripts to work around limitations:
|
|
20
|
+
- `fix-esm-imports.js` - Adds `.js` extensions for ESM compatibility
|
|
21
|
+
- `make-client-instantiable.js` - Transforms static methods to accept per-instance config
|
|
22
|
+
|
|
23
|
+
This approach is fragile and hard to maintain. This package generates better SDKs from scratch instead.
|
|
24
|
+
|
|
25
|
+
## Design Goals
|
|
26
|
+
|
|
27
|
+
1. **Instance-based configuration** - No global singletons, auth per client instance
|
|
28
|
+
2. **Ergonomic API** - Resource-based grouping, clean method signatures
|
|
29
|
+
3. **Type-safe** - Full TypeScript support with strict mode
|
|
30
|
+
4. **Streaming support** - SSE endpoints return AsyncIterable
|
|
31
|
+
5. **Simple generator** - Transparent code generation, easy to debug
|
|
32
|
+
6. **No post-processing** - Generate correct code from the start
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
### Three-Step Process
|
|
37
|
+
|
|
38
|
+
1. **Parse** (`parser.ts`) - Convert OpenAPI JSON to internal IR
|
|
39
|
+
2. **Generate** (`generator.ts`) - Produce TypeScript source files
|
|
40
|
+
3. **Emit** (`index.ts`) - Write files to disk
|
|
41
|
+
|
|
42
|
+
### Generated Structure
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
generated/
|
|
46
|
+
├── index.ts # Main exports
|
|
47
|
+
├── client.ts # ApiClient main class
|
|
48
|
+
├── base-client.ts # BaseClient with fetch wrapper
|
|
49
|
+
├── types.ts # Type definitions from schemas
|
|
50
|
+
├── users-resource.ts # UsersResource class
|
|
51
|
+
├── tasks-resource.ts # TasksResource class
|
|
52
|
+
└── stream-resource.ts # StreamResource class
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Usage Pattern
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
const client = new ApiClient({
|
|
59
|
+
baseUrl: 'http://localhost:3000',
|
|
60
|
+
getAccessToken: async () => getToken(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Resource-based API
|
|
64
|
+
await client.users.getUser({ id: '123' });
|
|
65
|
+
await client.tasks.create({ body: { title: 'Task' } });
|
|
66
|
+
|
|
67
|
+
// Streaming
|
|
68
|
+
for await (const event of client.stream.events({ count: 10 })) {
|
|
69
|
+
console.log(event);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// AbortSignal support
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
await client.users.list({ signal: controller.signal });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Key Features
|
|
78
|
+
|
|
79
|
+
### Instance-Based Auth
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
const client1 = new ApiClient({ baseUrl: 'http://api1.com', getAccessToken: () => token1 });
|
|
83
|
+
const client2 = new ApiClient({ baseUrl: 'http://api2.com', getAccessToken: () => token2 });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
No global state, no singleton issues.
|
|
87
|
+
|
|
88
|
+
### Resource Grouping
|
|
89
|
+
|
|
90
|
+
Operations are grouped by OpenAPI tags into resource classes:
|
|
91
|
+
- `client.users.*` - All user operations
|
|
92
|
+
- `client.tasks.*` - All task operations
|
|
93
|
+
- `client.stream.*` - All streaming operations
|
|
94
|
+
|
|
95
|
+
### Streaming Support
|
|
96
|
+
|
|
97
|
+
SSE endpoints automatically detected and generated as async generators:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
async *streamEvents(params): AsyncIterable<Event> {
|
|
101
|
+
yield* this.streamEvents('/stream/events', { params });
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Type Safety
|
|
106
|
+
|
|
107
|
+
- Request bodies typed from `requestBody` schema
|
|
108
|
+
- Path/query params typed from `parameters`
|
|
109
|
+
- Response types from `responses[200].content`
|
|
110
|
+
- Optional fields handled correctly
|
|
111
|
+
- Enums generated as union types
|
|
112
|
+
|
|
113
|
+
## Testing
|
|
114
|
+
|
|
115
|
+
The test suite is fully automated and self-contained under `test/`.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npm test
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
This runs the full pipeline:
|
|
122
|
+
1. **Clean** - Remove previously generated client
|
|
123
|
+
2. **Generate spec** - Start the test API in spec-generation mode, write `openapi.json`, exit
|
|
124
|
+
3. **Generate client** - Run the generator against the spec
|
|
125
|
+
4. **Run tests** - Start the test API server, run the test SDK against it, shut down
|
|
126
|
+
|
|
127
|
+
### Test API (`test/api/`)
|
|
128
|
+
|
|
129
|
+
NestJS app with endpoints exercising OpenAPI features:
|
|
130
|
+
- GET with path/query params
|
|
131
|
+
- POST/PUT/PATCH with JSON body
|
|
132
|
+
- DELETE with no content response
|
|
133
|
+
- Nested objects and arrays
|
|
134
|
+
- Optional fields
|
|
135
|
+
- SSE streaming (`/stream/events`)
|
|
136
|
+
- Auth headers and custom header parameters
|
|
137
|
+
- Query parameters with special characters
|
|
138
|
+
|
|
139
|
+
The test API generates `openapi.json` via `--generate-spec` mode (starts, writes file, exits immediately — no server needed).
|
|
140
|
+
|
|
141
|
+
### Test Client (`test/client/`)
|
|
142
|
+
|
|
143
|
+
`test-sdk.ts` imports the generated SDK and makes real HTTP calls to the test API, validating both types and runtime behavior including streaming and AbortSignal.
|
|
144
|
+
|
|
145
|
+
## Tradeoffs
|
|
146
|
+
|
|
147
|
+
### What We Gain
|
|
148
|
+
|
|
149
|
+
✅ No post-processing — generate correct code directly
|
|
150
|
+
✅ Cleaner code — no regex hacks on generated output
|
|
151
|
+
✅ Instance config — no global singleton
|
|
152
|
+
✅ Better DX — resource grouping, clear method names
|
|
153
|
+
✅ Streaming — first-class AsyncIterable support
|
|
154
|
+
✅ Maintainability — simple generator, easy to understand
|
|
155
|
+
|
|
156
|
+
### What We Lose
|
|
157
|
+
|
|
158
|
+
❌ More code — custom generator vs off-the-shelf tool
|
|
159
|
+
❌ Edge cases — may not handle all OpenAPI features yet
|
|
160
|
+
❌ Battle-tested — new code vs mature library
|
|
161
|
+
|
|
162
|
+
### Comparison to Current Approach
|
|
163
|
+
|
|
164
|
+
| Aspect | Current (`openapi-typescript-codegen`) | New Generator |
|
|
165
|
+
|--------|---------------------------------------|---------------|
|
|
166
|
+
| Post-processing | 2 scripts (fix-esm, make-instantiable) | None |
|
|
167
|
+
| Auth pattern | Global with per-call override | Instance-based |
|
|
168
|
+
| Resource grouping | Service classes | Resource classes |
|
|
169
|
+
| Streaming | Not supported | AsyncIterable |
|
|
170
|
+
| Maintainability | Fragile regex transforms | Transparent generation |
|
|
171
|
+
|
|
172
|
+
## Limitations
|
|
173
|
+
|
|
174
|
+
Current implementation doesn't handle:
|
|
175
|
+
- Complex schema compositions (allOf, oneOf, anyOf)
|
|
176
|
+
- Custom media types beyond JSON
|
|
177
|
+
- File uploads
|
|
178
|
+
- Cookie parameters
|
|
179
|
+
- Complex security schemes
|
|
180
|
+
|
|
181
|
+
## File Structure
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
packages/openapi-sdkgen/
|
|
185
|
+
├── src/
|
|
186
|
+
│ ├── types.ts # Internal IR types
|
|
187
|
+
│ ├── parser.ts # OpenAPI -> IR
|
|
188
|
+
│ ├── generator.ts # IR -> TypeScript
|
|
189
|
+
│ ├── index.ts # Main API
|
|
190
|
+
│ └── cli.ts # CLI tool
|
|
191
|
+
├── test/
|
|
192
|
+
│ ├── api/ # NestJS test API (spec source)
|
|
193
|
+
│ │ └── src/
|
|
194
|
+
│ │ ├── main.ts
|
|
195
|
+
│ │ ├── app.module.ts
|
|
196
|
+
│ │ ├── users.controller.ts
|
|
197
|
+
│ │ ├── tasks.controller.ts
|
|
198
|
+
│ │ └── stream.controller.ts
|
|
199
|
+
│ ├── client/ # Test SDK + generated output
|
|
200
|
+
│ │ ├── generated/ # Generated SDK (gitignored)
|
|
201
|
+
│ │ └── test-sdk.ts
|
|
202
|
+
│ └── run.mjs # Test orchestrator
|
|
203
|
+
├── package.json
|
|
204
|
+
├── tsconfig.json
|
|
205
|
+
└── README.md
|
|
206
|
+
```
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { generate } from './index.js';
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
if (args.length < 2) {
|
|
5
|
+
console.error('Usage: openapi-sdkgen <input-spec.json> <output-dir>');
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
const [inputPath, outputDir] = args;
|
|
9
|
+
generate({ inputPath, outputDir })
|
|
10
|
+
.then(() => {
|
|
11
|
+
console.log('✓ Client generation complete');
|
|
12
|
+
})
|
|
13
|
+
.catch((error) => {
|
|
14
|
+
console.error('Error generating client:', error);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
export function generateClient(spec) {
|
|
2
|
+
const files = new Map();
|
|
3
|
+
// Generate types file
|
|
4
|
+
files.set('types.ts', generateTypes(spec));
|
|
5
|
+
// Generate base client
|
|
6
|
+
files.set('base-client.ts', generateBaseClient());
|
|
7
|
+
// Generate resource classes
|
|
8
|
+
for (const resource of spec.resources) {
|
|
9
|
+
files.set(`${toKebabCase(resource.tag)}-resource.ts`, generateResource(resource));
|
|
10
|
+
}
|
|
11
|
+
// Generate main client class
|
|
12
|
+
files.set('client.ts', generateMainClient(spec));
|
|
13
|
+
// Generate index
|
|
14
|
+
files.set('index.ts', generateIndex(spec));
|
|
15
|
+
return files;
|
|
16
|
+
}
|
|
17
|
+
function generateTypes(spec) {
|
|
18
|
+
const lines = [
|
|
19
|
+
'// Auto-generated types from OpenAPI spec',
|
|
20
|
+
'// Do not edit manually',
|
|
21
|
+
'',
|
|
22
|
+
];
|
|
23
|
+
for (const [name, schema] of spec.schemas) {
|
|
24
|
+
lines.push(generateTypeDefinition(name, schema));
|
|
25
|
+
lines.push('');
|
|
26
|
+
}
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
function generateTypeDefinition(name, schema) {
|
|
30
|
+
if (schema.type === 'object' && schema.properties) {
|
|
31
|
+
const props = [];
|
|
32
|
+
for (const [propName, propSchema] of schema.properties) {
|
|
33
|
+
const optional = !schema.required?.includes(propName);
|
|
34
|
+
const propType = schemaToTypeScript(propSchema);
|
|
35
|
+
props.push(` ${propName}${optional ? '?' : ''}: ${propType};`);
|
|
36
|
+
}
|
|
37
|
+
return `export interface ${name} {\n${props.join('\n')}\n}`;
|
|
38
|
+
}
|
|
39
|
+
if (schema.enum) {
|
|
40
|
+
const values = schema.enum.map((v) => `'${v}'`).join(' | ');
|
|
41
|
+
return `export type ${name} = ${values};`;
|
|
42
|
+
}
|
|
43
|
+
return `export type ${name} = ${schemaToTypeScript(schema)};`;
|
|
44
|
+
}
|
|
45
|
+
function schemaToTypeScript(schema) {
|
|
46
|
+
if (schema.ref) {
|
|
47
|
+
return schema.ref;
|
|
48
|
+
}
|
|
49
|
+
if (schema.enum) {
|
|
50
|
+
return schema.enum.map((v) => `'${v}'`).join(' | ');
|
|
51
|
+
}
|
|
52
|
+
let baseType;
|
|
53
|
+
switch (schema.type) {
|
|
54
|
+
case 'string':
|
|
55
|
+
baseType = 'string';
|
|
56
|
+
break;
|
|
57
|
+
case 'number':
|
|
58
|
+
case 'integer':
|
|
59
|
+
baseType = 'number';
|
|
60
|
+
break;
|
|
61
|
+
case 'boolean':
|
|
62
|
+
baseType = 'boolean';
|
|
63
|
+
break;
|
|
64
|
+
case 'array':
|
|
65
|
+
baseType = schema.items ? `${schemaToTypeScript(schema.items)}[]` : 'any[]';
|
|
66
|
+
break;
|
|
67
|
+
case 'object':
|
|
68
|
+
if (schema.properties) {
|
|
69
|
+
const props = [];
|
|
70
|
+
for (const [propName, propSchema] of schema.properties) {
|
|
71
|
+
const optional = !schema.required?.includes(propName);
|
|
72
|
+
props.push(`${propName}${optional ? '?' : ''}: ${schemaToTypeScript(propSchema)}`);
|
|
73
|
+
}
|
|
74
|
+
baseType = `{ ${props.join('; ')} }`;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
baseType = 'Record<string, any>';
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
default:
|
|
81
|
+
baseType = 'any';
|
|
82
|
+
}
|
|
83
|
+
return schema.nullable ? `${baseType} | null` : baseType;
|
|
84
|
+
}
|
|
85
|
+
function generateBaseClient() {
|
|
86
|
+
return `// Base client with fetch wrapper
|
|
87
|
+
export interface ClientConfig {
|
|
88
|
+
baseUrl: string;
|
|
89
|
+
/** Bearer token auth: sets Authorization: Bearer <token> */
|
|
90
|
+
getAccessToken?: () => string | Promise<string>;
|
|
91
|
+
/** Cookie auth for Node.js: sets Cookie header directly */
|
|
92
|
+
getCookies?: () => string | Promise<string>;
|
|
93
|
+
/** Cookie auth for browsers: passed as fetch credentials option (e.g. 'include') */
|
|
94
|
+
credentials?: 'include' | 'same-origin' | 'omit';
|
|
95
|
+
headers?: Record<string, string>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class BaseClient {
|
|
99
|
+
constructor(protected config: ClientConfig) {}
|
|
100
|
+
|
|
101
|
+
private async buildAuthHeaders(): Promise<Record<string, string>> {
|
|
102
|
+
const headers: Record<string, string> = {};
|
|
103
|
+
if (this.config.getAccessToken) {
|
|
104
|
+
const token = await this.config.getAccessToken();
|
|
105
|
+
headers['Authorization'] = \`Bearer \${token}\`;
|
|
106
|
+
}
|
|
107
|
+
if (this.config.getCookies) {
|
|
108
|
+
headers['Cookie'] = await this.config.getCookies();
|
|
109
|
+
}
|
|
110
|
+
return headers;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
protected async request<T>(
|
|
114
|
+
method: string,
|
|
115
|
+
path: string,
|
|
116
|
+
options?: {
|
|
117
|
+
params?: Record<string, any>;
|
|
118
|
+
body?: any;
|
|
119
|
+
headers?: Record<string, string>;
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
}
|
|
122
|
+
): Promise<T> {
|
|
123
|
+
const url = new URL(path, this.config.baseUrl);
|
|
124
|
+
|
|
125
|
+
// Add query parameters
|
|
126
|
+
if (options?.params) {
|
|
127
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
128
|
+
if (value !== undefined && value !== null) {
|
|
129
|
+
url.searchParams.append(key, String(value));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Build headers (filter out undefined values)
|
|
135
|
+
const headers: Record<string, string> = {};
|
|
136
|
+
|
|
137
|
+
// Add config headers
|
|
138
|
+
if (this.config.headers) {
|
|
139
|
+
for (const [key, value] of Object.entries(this.config.headers)) {
|
|
140
|
+
if (value !== undefined && value !== null) {
|
|
141
|
+
headers[key] = String(value);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Add request-specific headers
|
|
147
|
+
if (options?.headers) {
|
|
148
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
149
|
+
if (value !== undefined && value !== null) {
|
|
150
|
+
headers[key] = String(value);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Add auth headers
|
|
156
|
+
Object.assign(headers, await this.buildAuthHeaders());
|
|
157
|
+
|
|
158
|
+
// Add content type for body requests
|
|
159
|
+
if (options?.body && !headers['Content-Type']) {
|
|
160
|
+
headers['Content-Type'] = 'application/json';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const response = await fetch(url.toString(), {
|
|
164
|
+
method,
|
|
165
|
+
headers,
|
|
166
|
+
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
167
|
+
signal: options?.signal,
|
|
168
|
+
credentials: this.config.credentials,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle no content responses
|
|
176
|
+
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
|
177
|
+
return undefined as T;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const contentType = response.headers.get('content-type');
|
|
181
|
+
if (contentType?.includes('application/json')) {
|
|
182
|
+
return response.json() as Promise<T>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return response.text() as any;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
protected async *streamEvents<T = any>(
|
|
189
|
+
path: string,
|
|
190
|
+
options?: {
|
|
191
|
+
params?: Record<string, any>;
|
|
192
|
+
headers?: Record<string, string>;
|
|
193
|
+
signal?: AbortSignal;
|
|
194
|
+
}
|
|
195
|
+
): AsyncIterable<T> {
|
|
196
|
+
const url = new URL(path, this.config.baseUrl);
|
|
197
|
+
|
|
198
|
+
if (options?.params) {
|
|
199
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
200
|
+
if (value !== undefined && value !== null) {
|
|
201
|
+
url.searchParams.append(key, String(value));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Build headers (filter out undefined values)
|
|
207
|
+
const headers: Record<string, string> = {
|
|
208
|
+
'Accept': 'text/event-stream',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Add config headers
|
|
212
|
+
if (this.config.headers) {
|
|
213
|
+
for (const [key, value] of Object.entries(this.config.headers)) {
|
|
214
|
+
if (value !== undefined && value !== null) {
|
|
215
|
+
headers[key] = String(value);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Add request-specific headers
|
|
221
|
+
if (options?.headers) {
|
|
222
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
223
|
+
if (value !== undefined && value !== null) {
|
|
224
|
+
headers[key] = String(value);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Add auth headers
|
|
230
|
+
Object.assign(headers, await this.buildAuthHeaders());
|
|
231
|
+
|
|
232
|
+
const response = await fetch(url.toString(), {
|
|
233
|
+
method: 'GET',
|
|
234
|
+
headers,
|
|
235
|
+
signal: options?.signal,
|
|
236
|
+
credentials: this.config.credentials,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!response.body) {
|
|
244
|
+
throw new Error('Response body is null');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const reader = response.body.getReader();
|
|
248
|
+
const decoder = new TextDecoder();
|
|
249
|
+
let buffer = '';
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
while (true) {
|
|
253
|
+
const { done, value } = await reader.read();
|
|
254
|
+
if (done) break;
|
|
255
|
+
|
|
256
|
+
buffer += decoder.decode(value, { stream: true });
|
|
257
|
+
const lines = buffer.split('\\n');
|
|
258
|
+
buffer = lines.pop() || '';
|
|
259
|
+
|
|
260
|
+
for (const line of lines) {
|
|
261
|
+
if (line.startsWith('data: ')) {
|
|
262
|
+
const data = line.slice(6);
|
|
263
|
+
try {
|
|
264
|
+
yield JSON.parse(data) as T;
|
|
265
|
+
} catch {
|
|
266
|
+
yield data as T;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
reader.releaseLock();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
`;
|
|
277
|
+
}
|
|
278
|
+
function generateResource(resource) {
|
|
279
|
+
// Collect all type references used in this resource
|
|
280
|
+
const typeRefs = new Set();
|
|
281
|
+
for (const op of resource.operations) {
|
|
282
|
+
// Collect types from parameters
|
|
283
|
+
for (const param of op.parameters) {
|
|
284
|
+
collectTypeRefs(param.schema, typeRefs);
|
|
285
|
+
}
|
|
286
|
+
// Collect types from request body
|
|
287
|
+
if (op.requestBody?.content) {
|
|
288
|
+
for (const [, mediaType] of op.requestBody.content) {
|
|
289
|
+
collectTypeRefs(mediaType.schema, typeRefs);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Collect types from responses
|
|
293
|
+
for (const [, response] of op.responses) {
|
|
294
|
+
if (response.content) {
|
|
295
|
+
for (const [, mediaType] of response.content) {
|
|
296
|
+
collectTypeRefs(mediaType.schema, typeRefs);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const lines = [
|
|
302
|
+
`import { BaseClient, ClientConfig } from './base-client.js';`,
|
|
303
|
+
];
|
|
304
|
+
// Add type imports if any types are referenced
|
|
305
|
+
if (typeRefs.size > 0) {
|
|
306
|
+
const sortedTypes = Array.from(typeRefs).sort();
|
|
307
|
+
lines.push(`import type { ${sortedTypes.join(', ')} } from './types.js';`);
|
|
308
|
+
}
|
|
309
|
+
lines.push('');
|
|
310
|
+
lines.push(`export class ${resource.name} extends BaseClient {`);
|
|
311
|
+
lines.push(` constructor(config: ClientConfig) {`);
|
|
312
|
+
lines.push(` super(config);`);
|
|
313
|
+
lines.push(` }`);
|
|
314
|
+
lines.push('');
|
|
315
|
+
for (const op of resource.operations) {
|
|
316
|
+
lines.push(generateOperation(op));
|
|
317
|
+
lines.push('');
|
|
318
|
+
}
|
|
319
|
+
lines.push('}');
|
|
320
|
+
return lines.join('\n');
|
|
321
|
+
}
|
|
322
|
+
function collectTypeRefs(schema, refs) {
|
|
323
|
+
if (schema.ref) {
|
|
324
|
+
refs.add(schema.ref);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (schema.type === 'array' && schema.items) {
|
|
328
|
+
collectTypeRefs(schema.items, refs);
|
|
329
|
+
}
|
|
330
|
+
if (schema.type === 'object' && schema.properties) {
|
|
331
|
+
for (const [, propSchema] of schema.properties) {
|
|
332
|
+
collectTypeRefs(propSchema, refs);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function generateOperation(op) {
|
|
337
|
+
const lines = [];
|
|
338
|
+
// Determine if this is a streaming endpoint by checking response content-type
|
|
339
|
+
const isStreaming = isStreamingEndpoint(op);
|
|
340
|
+
// Build method signature
|
|
341
|
+
// Use operationId directly as method name (already in desired format from OpenAPI)
|
|
342
|
+
const methodName = op.operationId;
|
|
343
|
+
const { params, isParamsOptional } = buildMethodParams(op);
|
|
344
|
+
const returnType = inferReturnType(op, isStreaming);
|
|
345
|
+
// Add JSDoc comment
|
|
346
|
+
if (op.summary) {
|
|
347
|
+
lines.push(` /** ${op.summary} */`);
|
|
348
|
+
}
|
|
349
|
+
lines.push(` async ${isStreaming ? '*' : ''}${methodName}(${params}): ${returnType} {`);
|
|
350
|
+
// Build path with path parameters
|
|
351
|
+
const pathParams = op.parameters.filter((p) => p.in === 'path');
|
|
352
|
+
let pathExpr;
|
|
353
|
+
if (pathParams.length > 0) {
|
|
354
|
+
// Use template literal for paths with parameters
|
|
355
|
+
let path = op.path;
|
|
356
|
+
for (const param of pathParams) {
|
|
357
|
+
const accessor = needsQuoting(param.name) ? `['${param.name}']` : `.${param.name}`;
|
|
358
|
+
const paramRef = isParamsOptional ? `params?${accessor}` : `params${accessor}`;
|
|
359
|
+
path = path.replace(`{${param.name}}`, `\${${paramRef}}`);
|
|
360
|
+
}
|
|
361
|
+
pathExpr = `\`${path}\``;
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
pathExpr = `'${op.path}'`;
|
|
365
|
+
}
|
|
366
|
+
// Build query parameters
|
|
367
|
+
const queryParams = op.parameters.filter((p) => p.in === 'query');
|
|
368
|
+
const hasQueryParams = queryParams.length > 0;
|
|
369
|
+
// Build header parameters
|
|
370
|
+
const headerParams = op.parameters.filter((p) => p.in === 'header');
|
|
371
|
+
const hasHeaderParams = headerParams.length > 0;
|
|
372
|
+
// Build request options
|
|
373
|
+
const requestOptions = [];
|
|
374
|
+
const paramsPrefix = isParamsOptional ? 'params?.' : 'params.';
|
|
375
|
+
if (hasQueryParams) {
|
|
376
|
+
const queryObj = queryParams
|
|
377
|
+
.map((p) => {
|
|
378
|
+
const needsQuote = needsQuoting(p.name);
|
|
379
|
+
const quotedKey = quoteIfNeeded(p.name);
|
|
380
|
+
let paramRef;
|
|
381
|
+
if (isParamsOptional) {
|
|
382
|
+
paramRef = needsQuote ? `params?.['${p.name}']` : `params?.${p.name}`;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
paramRef = needsQuote ? `params['${p.name}']` : `params.${p.name}`;
|
|
386
|
+
}
|
|
387
|
+
return `${quotedKey}: ${paramRef}`;
|
|
388
|
+
})
|
|
389
|
+
.join(', ');
|
|
390
|
+
requestOptions.push(`params: { ${queryObj} }`);
|
|
391
|
+
}
|
|
392
|
+
if (hasHeaderParams) {
|
|
393
|
+
const headersObj = headerParams
|
|
394
|
+
.map((p) => {
|
|
395
|
+
const needsQuote = needsQuoting(p.name);
|
|
396
|
+
let paramRef;
|
|
397
|
+
if (isParamsOptional) {
|
|
398
|
+
paramRef = needsQuote ? `params?.['${p.name}']` : `params?.${p.name}`;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
paramRef = needsQuote ? `params['${p.name}']` : `params.${p.name}`;
|
|
402
|
+
}
|
|
403
|
+
return `'${p.name}': ${paramRef}`;
|
|
404
|
+
})
|
|
405
|
+
.join(', ');
|
|
406
|
+
requestOptions.push(`headers: { ${headersObj} }`);
|
|
407
|
+
}
|
|
408
|
+
if (op.requestBody) {
|
|
409
|
+
requestOptions.push(`body: ${paramsPrefix}body`);
|
|
410
|
+
}
|
|
411
|
+
if (params.includes('signal')) {
|
|
412
|
+
requestOptions.push('signal: params?.signal');
|
|
413
|
+
}
|
|
414
|
+
if (isStreaming) {
|
|
415
|
+
lines.push(` yield* this.streamEvents(${pathExpr}${requestOptions.length > 0 ? `, { ${requestOptions.join(', ')} }` : ''});`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
lines.push(` return this.request('${op.method}', ${pathExpr}${requestOptions.length > 0 ? `, { ${requestOptions.join(', ')} }` : ''});`);
|
|
419
|
+
}
|
|
420
|
+
lines.push(' }');
|
|
421
|
+
return lines.join('\n');
|
|
422
|
+
}
|
|
423
|
+
function isStreamingEndpoint(op) {
|
|
424
|
+
// Check if any successful response has text/event-stream content type
|
|
425
|
+
for (const [status, response] of op.responses.entries()) {
|
|
426
|
+
if (status >= 200 && status < 300 && response.content) {
|
|
427
|
+
for (const contentType of response.content.keys()) {
|
|
428
|
+
if (contentType.includes('text/event-stream')) {
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
function buildMethodParams(op) {
|
|
437
|
+
const params = [];
|
|
438
|
+
// Collect all parameters
|
|
439
|
+
const pathParams = op.parameters.filter((p) => p.in === 'path');
|
|
440
|
+
const queryParams = op.parameters.filter((p) => p.in === 'query');
|
|
441
|
+
const headerParams = op.parameters.filter((p) => p.in === 'header');
|
|
442
|
+
let isParamsOptional = false;
|
|
443
|
+
if (pathParams.length > 0 || queryParams.length > 0 || headerParams.length > 0 || op.requestBody) {
|
|
444
|
+
const paramFields = [];
|
|
445
|
+
for (const param of [...pathParams, ...queryParams, ...headerParams]) {
|
|
446
|
+
const optional = !param.required;
|
|
447
|
+
const quotedName = quoteIfNeeded(param.name);
|
|
448
|
+
paramFields.push(`${quotedName}${optional ? '?' : ''}: ${schemaToTypeScript(param.schema)}`);
|
|
449
|
+
}
|
|
450
|
+
if (op.requestBody) {
|
|
451
|
+
const bodySchema = op.requestBody.content.get('application/json');
|
|
452
|
+
if (bodySchema) {
|
|
453
|
+
const optional = !op.requestBody.required;
|
|
454
|
+
paramFields.push(`body${optional ? '?' : ''}: ${schemaToTypeScript(bodySchema.schema)}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
paramFields.push('signal?: AbortSignal');
|
|
458
|
+
// Check if params object should be optional (all fields are optional)
|
|
459
|
+
const hasRequiredParams = pathParams.some(p => p.required) ||
|
|
460
|
+
queryParams.some(p => p.required) ||
|
|
461
|
+
headerParams.some(p => p.required) ||
|
|
462
|
+
(op.requestBody?.required ?? false);
|
|
463
|
+
isParamsOptional = !hasRequiredParams;
|
|
464
|
+
const paramsOptional = isParamsOptional ? '?' : '';
|
|
465
|
+
params.push(`params${paramsOptional}: { ${paramFields.join('; ')} }`);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
params.push('params?: { signal?: AbortSignal }');
|
|
469
|
+
isParamsOptional = true;
|
|
470
|
+
}
|
|
471
|
+
return { params: params.join(', '), isParamsOptional };
|
|
472
|
+
}
|
|
473
|
+
function inferReturnType(op, isStreaming) {
|
|
474
|
+
const successResponse = op.responses.get(200) || op.responses.get(201);
|
|
475
|
+
if (!successResponse?.content) {
|
|
476
|
+
return isStreaming ? 'AsyncIterable<any>' : 'Promise<void>';
|
|
477
|
+
}
|
|
478
|
+
const jsonContent = successResponse.content.get('application/json');
|
|
479
|
+
if (!jsonContent) {
|
|
480
|
+
return isStreaming ? 'AsyncIterable<any>' : 'Promise<any>';
|
|
481
|
+
}
|
|
482
|
+
const tsType = schemaToTypeScript(jsonContent.schema);
|
|
483
|
+
if (isStreaming) {
|
|
484
|
+
return `AsyncIterable<${tsType}>`;
|
|
485
|
+
}
|
|
486
|
+
return `Promise<${tsType}>`;
|
|
487
|
+
}
|
|
488
|
+
function generateMainClient(spec) {
|
|
489
|
+
const lines = [
|
|
490
|
+
`import { ClientConfig } from './base-client.js';`,
|
|
491
|
+
];
|
|
492
|
+
for (const resource of spec.resources) {
|
|
493
|
+
lines.push(`import { ${resource.name} } from './${toKebabCase(resource.tag)}-resource.js';`);
|
|
494
|
+
}
|
|
495
|
+
lines.push('');
|
|
496
|
+
lines.push(`export class ApiClient {`);
|
|
497
|
+
for (const resource of spec.resources) {
|
|
498
|
+
lines.push(` public readonly ${toCamelCase(resource.tag)}: ${resource.name};`);
|
|
499
|
+
}
|
|
500
|
+
lines.push('');
|
|
501
|
+
lines.push(` constructor(config: ClientConfig) {`);
|
|
502
|
+
for (const resource of spec.resources) {
|
|
503
|
+
lines.push(` this.${toCamelCase(resource.tag)} = new ${resource.name}(config);`);
|
|
504
|
+
}
|
|
505
|
+
lines.push(' }');
|
|
506
|
+
lines.push('}');
|
|
507
|
+
return lines.join('\n');
|
|
508
|
+
}
|
|
509
|
+
function generateIndex(spec) {
|
|
510
|
+
const lines = [
|
|
511
|
+
`export { ApiClient } from './client.js';`,
|
|
512
|
+
`export type { ClientConfig } from './base-client.js';`,
|
|
513
|
+
`export * from './types.js';`,
|
|
514
|
+
];
|
|
515
|
+
return lines.join('\n');
|
|
516
|
+
}
|
|
517
|
+
function toCamelCase(str) {
|
|
518
|
+
// Convert to camelCase by:
|
|
519
|
+
// 1. Replace special chars and separators with spaces
|
|
520
|
+
// 2. Split on whitespace/separators
|
|
521
|
+
// 3. Lowercase first word, capitalize rest
|
|
522
|
+
// 4. Join without spaces
|
|
523
|
+
const words = str
|
|
524
|
+
.replace(/[-_]/g, ' ') // Replace hyphens and underscores with spaces
|
|
525
|
+
.split(/\s+/) // Split on whitespace
|
|
526
|
+
.filter(word => word.length > 0); // Remove empty strings
|
|
527
|
+
if (words.length === 0)
|
|
528
|
+
return str;
|
|
529
|
+
return words[0].toLowerCase() +
|
|
530
|
+
words.slice(1)
|
|
531
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
532
|
+
.join('');
|
|
533
|
+
}
|
|
534
|
+
function toKebabCase(str) {
|
|
535
|
+
return str
|
|
536
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
537
|
+
.replace(/[\s_]+/g, '-')
|
|
538
|
+
.toLowerCase();
|
|
539
|
+
}
|
|
540
|
+
function needsQuoting(identifier) {
|
|
541
|
+
// Check if identifier needs quoting (contains hyphens or other special chars)
|
|
542
|
+
return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(identifier);
|
|
543
|
+
}
|
|
544
|
+
function quoteIfNeeded(identifier) {
|
|
545
|
+
return needsQuoting(identifier) ? `'${identifier}'` : identifier;
|
|
546
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { parseOpenAPISpec } from './parser.js';
|
|
4
|
+
import { generateClient } from './generator.js';
|
|
5
|
+
export async function generate(config) {
|
|
6
|
+
// Read OpenAPI spec
|
|
7
|
+
const specContent = readFileSync(config.inputPath, 'utf-8');
|
|
8
|
+
const spec = JSON.parse(specContent);
|
|
9
|
+
// Parse spec
|
|
10
|
+
const parsed = parseOpenAPISpec(spec);
|
|
11
|
+
// Generate files
|
|
12
|
+
const files = generateClient(parsed);
|
|
13
|
+
// Write files
|
|
14
|
+
mkdirSync(config.outputDir, { recursive: true });
|
|
15
|
+
for (const [filename, content] of files) {
|
|
16
|
+
const filePath = join(config.outputDir, filename);
|
|
17
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
18
|
+
console.log(`Generated: ${filePath}`);
|
|
19
|
+
}
|
|
20
|
+
console.log(`✓ Generated ${files.size} files in ${config.outputDir}`);
|
|
21
|
+
}
|
|
22
|
+
export * from './types.js';
|
|
23
|
+
export { parseOpenAPISpec } from './parser.js';
|
|
24
|
+
export { generateClient } from './generator.js';
|
package/dist/parser.d.ts
ADDED
package/dist/parser.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export function parseOpenAPISpec(spec) {
|
|
2
|
+
const schemas = parseSchemas(spec.components?.schemas || {});
|
|
3
|
+
const operations = parseOperations(spec.paths || {});
|
|
4
|
+
// Group operations by tag
|
|
5
|
+
const resourceMap = new Map();
|
|
6
|
+
for (const op of operations) {
|
|
7
|
+
const tag = op.tags[0] || 'default';
|
|
8
|
+
if (!resourceMap.has(tag)) {
|
|
9
|
+
resourceMap.set(tag, []);
|
|
10
|
+
}
|
|
11
|
+
resourceMap.get(tag).push(op);
|
|
12
|
+
}
|
|
13
|
+
const resources = Array.from(resourceMap.entries()).map(([tag, ops]) => ({
|
|
14
|
+
name: toPascalCase(tag) + 'Resource',
|
|
15
|
+
tag,
|
|
16
|
+
operations: ops,
|
|
17
|
+
}));
|
|
18
|
+
return {
|
|
19
|
+
info: {
|
|
20
|
+
title: spec.info?.title || 'API Client',
|
|
21
|
+
version: spec.info?.version || '1.0.0',
|
|
22
|
+
},
|
|
23
|
+
resources,
|
|
24
|
+
schemas,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function parseOperations(paths) {
|
|
28
|
+
const operations = [];
|
|
29
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
30
|
+
// Parse path-level parameters (common to all operations on this path)
|
|
31
|
+
const pathLevelParams = parseParameters(pathItem.parameters || []);
|
|
32
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
33
|
+
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const op = operation;
|
|
37
|
+
// Merge path-level and operation-level parameters (operation takes precedence)
|
|
38
|
+
const opParams = parseParameters(op.parameters || []);
|
|
39
|
+
const params = [...pathLevelParams];
|
|
40
|
+
// Add operation params, overriding path-level params with same name/in combination
|
|
41
|
+
for (const opParam of opParams) {
|
|
42
|
+
const existingIndex = params.findIndex(p => p.name === opParam.name && p.in === opParam.in);
|
|
43
|
+
if (existingIndex >= 0) {
|
|
44
|
+
params[existingIndex] = opParam; // Operation-level takes precedence
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
params.push(opParam);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Extract path parameters from the path string if not already in parameters
|
|
51
|
+
const pathParamMatches = path.matchAll(/\{([^}]+)\}/g);
|
|
52
|
+
for (const match of pathParamMatches) {
|
|
53
|
+
const paramName = match[1];
|
|
54
|
+
// Only add if not already present
|
|
55
|
+
if (!params.find(p => p.name === paramName && p.in === 'path')) {
|
|
56
|
+
params.push({
|
|
57
|
+
name: paramName,
|
|
58
|
+
in: 'path',
|
|
59
|
+
required: true,
|
|
60
|
+
schema: { type: 'string' },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
operations.push({
|
|
65
|
+
operationId: op.operationId || `${method}${path.replace(/\//g, '_')}`,
|
|
66
|
+
method: method.toUpperCase(),
|
|
67
|
+
path,
|
|
68
|
+
summary: op.summary,
|
|
69
|
+
parameters: params,
|
|
70
|
+
requestBody: op.requestBody ? parseRequestBody(op.requestBody) : undefined,
|
|
71
|
+
responses: parseResponses(op.responses || {}),
|
|
72
|
+
tags: op.tags || [],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return operations;
|
|
77
|
+
}
|
|
78
|
+
function parseParameters(parameters) {
|
|
79
|
+
return parameters.map((param) => ({
|
|
80
|
+
name: param.name,
|
|
81
|
+
in: param.in,
|
|
82
|
+
required: param.required || false,
|
|
83
|
+
schema: parseSchema(param.schema),
|
|
84
|
+
description: param.description,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
function parseRequestBody(requestBody) {
|
|
88
|
+
const content = new Map();
|
|
89
|
+
for (const [contentType, mediaType] of Object.entries(requestBody.content || {})) {
|
|
90
|
+
content.set(contentType, {
|
|
91
|
+
schema: parseSchema(mediaType.schema),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
required: requestBody.required || false,
|
|
96
|
+
content,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function parseResponses(responses) {
|
|
100
|
+
const result = new Map();
|
|
101
|
+
for (const [status, response] of Object.entries(responses)) {
|
|
102
|
+
const resp = response;
|
|
103
|
+
const content = new Map();
|
|
104
|
+
if (resp.content) {
|
|
105
|
+
for (const [contentType, mediaType] of Object.entries(resp.content)) {
|
|
106
|
+
content.set(contentType, {
|
|
107
|
+
schema: parseSchema(mediaType.schema),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
result.set(Number(status), {
|
|
112
|
+
description: resp.description || '',
|
|
113
|
+
content: content.size > 0 ? content : undefined,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function parseSchema(schema) {
|
|
119
|
+
if (!schema) {
|
|
120
|
+
return { type: 'any' };
|
|
121
|
+
}
|
|
122
|
+
if (schema.$ref) {
|
|
123
|
+
return {
|
|
124
|
+
type: 'ref',
|
|
125
|
+
ref: schema.$ref.replace('#/components/schemas/', ''),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const info = {
|
|
129
|
+
type: schema.type || 'any',
|
|
130
|
+
format: schema.format,
|
|
131
|
+
nullable: schema.nullable,
|
|
132
|
+
};
|
|
133
|
+
if (schema.enum) {
|
|
134
|
+
info.enum = schema.enum;
|
|
135
|
+
}
|
|
136
|
+
if (schema.type === 'array' && schema.items) {
|
|
137
|
+
info.items = parseSchema(schema.items);
|
|
138
|
+
}
|
|
139
|
+
if (schema.type === 'object' && schema.properties) {
|
|
140
|
+
const props = new Map();
|
|
141
|
+
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
142
|
+
props.set(propName, parseSchema(propSchema));
|
|
143
|
+
}
|
|
144
|
+
info.properties = props;
|
|
145
|
+
info.required = schema.required || [];
|
|
146
|
+
}
|
|
147
|
+
return info;
|
|
148
|
+
}
|
|
149
|
+
function parseSchemas(schemas) {
|
|
150
|
+
const result = new Map();
|
|
151
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
152
|
+
result.set(name, parseSchema(schema));
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
function capitalize(str) {
|
|
157
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
158
|
+
}
|
|
159
|
+
function toPascalCase(str) {
|
|
160
|
+
// Convert to PascalCase by:
|
|
161
|
+
// 1. Replace special chars and separators with spaces
|
|
162
|
+
// 2. Split on whitespace/separators
|
|
163
|
+
// 3. Capitalize first letter of each word
|
|
164
|
+
// 4. Join without spaces
|
|
165
|
+
return str
|
|
166
|
+
.replace(/[-_]/g, ' ') // Replace hyphens and underscores with spaces
|
|
167
|
+
.split(/\s+/) // Split on whitespace
|
|
168
|
+
.filter(word => word.length > 0) // Remove empty strings
|
|
169
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
170
|
+
.join('');
|
|
171
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal representation of OpenAPI spec for code generation
|
|
3
|
+
*/
|
|
4
|
+
export interface ParsedSpec {
|
|
5
|
+
info: {
|
|
6
|
+
title: string;
|
|
7
|
+
version: string;
|
|
8
|
+
};
|
|
9
|
+
resources: Resource[];
|
|
10
|
+
schemas: Map<string, SchemaInfo>;
|
|
11
|
+
}
|
|
12
|
+
export interface Resource {
|
|
13
|
+
name: string;
|
|
14
|
+
tag: string;
|
|
15
|
+
operations: Operation[];
|
|
16
|
+
}
|
|
17
|
+
export interface Operation {
|
|
18
|
+
operationId: string;
|
|
19
|
+
method: string;
|
|
20
|
+
path: string;
|
|
21
|
+
summary?: string;
|
|
22
|
+
parameters: Parameter[];
|
|
23
|
+
requestBody?: RequestBody;
|
|
24
|
+
responses: Map<number, Response>;
|
|
25
|
+
tags: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface Parameter {
|
|
28
|
+
name: string;
|
|
29
|
+
in: 'path' | 'query' | 'header';
|
|
30
|
+
required: boolean;
|
|
31
|
+
schema: SchemaInfo;
|
|
32
|
+
description?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface RequestBody {
|
|
35
|
+
required: boolean;
|
|
36
|
+
content: Map<string, MediaType>;
|
|
37
|
+
}
|
|
38
|
+
export interface MediaType {
|
|
39
|
+
schema: SchemaInfo;
|
|
40
|
+
}
|
|
41
|
+
export interface Response {
|
|
42
|
+
description: string;
|
|
43
|
+
content?: Map<string, MediaType>;
|
|
44
|
+
}
|
|
45
|
+
export interface SchemaInfo {
|
|
46
|
+
type: string;
|
|
47
|
+
format?: string;
|
|
48
|
+
items?: SchemaInfo;
|
|
49
|
+
properties?: Map<string, SchemaInfo>;
|
|
50
|
+
required?: string[];
|
|
51
|
+
enum?: string[];
|
|
52
|
+
ref?: string;
|
|
53
|
+
nullable?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface GeneratorConfig {
|
|
56
|
+
inputPath: string;
|
|
57
|
+
outputDir: string;
|
|
58
|
+
clientClassName?: string;
|
|
59
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@taico/openapi-sdkgen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"package.json"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"openapi-sdkgen": "./dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"dev": "tsc --watch",
|
|
28
|
+
"test:api": "tsx --tsconfig test/api/tsconfig.json test/api/src/main.ts",
|
|
29
|
+
"test:client": "tsx test/client/test-sdk.ts",
|
|
30
|
+
"generate:spec": "tsx --tsconfig test/api/tsconfig.json test/api/src/main.ts --generate-spec",
|
|
31
|
+
"clean": "rm -rf test/client/generated",
|
|
32
|
+
"generate": "tsx src/cli.ts test/api/openapi.json test/client/generated",
|
|
33
|
+
"lint:generated": "tsc --noEmit --project test/client/tsconfig.json",
|
|
34
|
+
"test": "npm run clean && npm run generate:spec && npm run generate && npm run lint:generated && node test/run.mjs",
|
|
35
|
+
"pack": "npm run build && npm pack",
|
|
36
|
+
"release": "npm run build && npm publish --access public"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@nestjs/common": "^11.0.0",
|
|
40
|
+
"@nestjs/core": "^11.0.0",
|
|
41
|
+
"@nestjs/platform-express": "^11.0.0",
|
|
42
|
+
"@nestjs/swagger": "^11.2.1",
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"reflect-metadata": "^0.2.0",
|
|
45
|
+
"rxjs": "^7.8.1",
|
|
46
|
+
"tsx": "^4.7.0",
|
|
47
|
+
"typescript": "^5.6.3"
|
|
48
|
+
}
|
|
49
|
+
}
|