genoc 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/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/analyzer/naming.d.ts +24 -0
- package/dist/analyzer/naming.js +122 -0
- package/dist/analyzer/path-analyzer.d.ts +53 -0
- package/dist/analyzer/path-analyzer.js +222 -0
- package/dist/analyzer/schema-mapper.d.ts +48 -0
- package/dist/analyzer/schema-mapper.js +435 -0
- package/dist/cli/app.d.ts +9 -0
- package/dist/cli/app.js +60 -0
- package/dist/cli/errors.d.ts +3 -0
- package/dist/cli/errors.js +6 -0
- package/dist/cli/impl.d.ts +3 -0
- package/dist/cli/impl.js +45 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +5 -0
- package/dist/generator/client-generator.d.ts +21 -0
- package/dist/generator/client-generator.js +287 -0
- package/dist/generator/contracts-generator.d.ts +16 -0
- package/dist/generator/contracts-generator.js +525 -0
- package/dist/generator/error-types.d.ts +24 -0
- package/dist/generator/error-types.js +94 -0
- package/dist/generator/method-generator.d.ts +9 -0
- package/dist/generator/method-generator.js +249 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/parser/ref-resolver.d.ts +24 -0
- package/dist/parser/ref-resolver.js +119 -0
- package/dist/parser/spec-reader.d.ts +4 -0
- package/dist/parser/spec-reader.js +116 -0
- package/dist/parser/validators.d.ts +7 -0
- package/dist/parser/validators.js +79 -0
- package/dist/parser/version/index.d.ts +18 -0
- package/dist/parser/version/index.js +16 -0
- package/dist/parser/version/normalized-spec.d.ts +199 -0
- package/dist/parser/version/normalized-spec.js +1 -0
- package/dist/parser/version/registry.d.ts +28 -0
- package/dist/parser/version/registry.js +44 -0
- package/dist/parser/version/v3.0/index.d.ts +3 -0
- package/dist/parser/version/v3.0/index.js +3 -0
- package/dist/parser/version/v3.0/normalizer.d.ts +15 -0
- package/dist/parser/version/v3.0/normalizer.js +389 -0
- package/dist/parser/version/v3.0/strategy.d.ts +27 -0
- package/dist/parser/version/v3.0/strategy.js +96 -0
- package/dist/parser/version/v3.0/validator.d.ts +13 -0
- package/dist/parser/version/v3.0/validator.js +117 -0
- package/dist/parser/version/v3.1/index.d.ts +1 -0
- package/dist/parser/version/v3.1/index.js +1 -0
- package/dist/parser/version/v3.1/strategy.d.ts +42 -0
- package/dist/parser/version/v3.1/strategy.js +513 -0
- package/dist/parser/version/v3.2/index.d.ts +4 -0
- package/dist/parser/version/v3.2/index.js +4 -0
- package/dist/parser/version/v3.2/strategy.d.ts +39 -0
- package/dist/parser/version/v3.2/strategy.js +57 -0
- package/dist/parser/version/version-detector.d.ts +4 -0
- package/dist/parser/version/version-detector.js +34 -0
- package/dist/parser/version/version-strategy.d.ts +31 -0
- package/dist/parser/version/version-strategy.js +1 -0
- package/dist/types/client.d.ts +25 -0
- package/dist/types/client.js +1 -0
- package/dist/types/contracts.d.ts +13 -0
- package/dist/types/contracts.js +1 -0
- package/dist/types/openapi.d.ts +173 -0
- package/dist/types/openapi.js +1 -0
- package/dist/utils/case.d.ts +5 -0
- package/dist/utils/case.js +51 -0
- package/dist/utils/generator-helpers.d.ts +23 -0
- package/dist/utils/generator-helpers.js +66 -0
- package/dist/utils/string.d.ts +34 -0
- package/dist/utils/string.js +182 -0
- package/dist/utils/url.d.ts +10 -0
- package/dist/utils/url.js +40 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Andrey Kiselev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# genoc
|
|
2
|
+
|
|
3
|
+
Generate TypeScript HTTP clients from OpenAPI 3.0 / 3.1 specifications.
|
|
4
|
+
The generated code has zero runtime dependencies. Full type safety. Bring your own HTTP client.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/genoc)
|
|
7
|
+
[](https://nodejs.org/)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Full OpenAPI 3.0 and 3.1 specification support with automatic version detection
|
|
14
|
+
- End-to-end type safety — requests, responses, and errors are fully typed
|
|
15
|
+
- HTTP-client agnostic — adapter pattern lets you plug in fetch, axios, or anything else
|
|
16
|
+
- Error types with per-status-code narrowing and type guards
|
|
17
|
+
- File and binary upload/download with stream handling
|
|
18
|
+
- Flexible method naming strategies (path-based, operationId, operationId-with-fallback)
|
|
19
|
+
- CLI and programmatic API
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
Install:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -D genoc
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Generate:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
genoc ./path/to/spec.yaml --output-dir ./src/api
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This creates two files in `./src/api`:
|
|
36
|
+
|
|
37
|
+
- `contracts.ts` — Type definitions, error classes, and helper types
|
|
38
|
+
- `client.ts` — Typed client with `createClient(requester)` factory
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
The generated client requires a `Requester` implementation — a function that
|
|
43
|
+
performs the actual HTTP call and returns the result. This is the type your
|
|
44
|
+
implementation must satisfy:
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
type Requester = <TResponse>(
|
|
48
|
+
method: string,
|
|
49
|
+
path: string,
|
|
50
|
+
options: {
|
|
51
|
+
query?: Record<string, unknown>;
|
|
52
|
+
body?: unknown;
|
|
53
|
+
headers?: Record<string, string>;
|
|
54
|
+
expectStream?: true;
|
|
55
|
+
}
|
|
56
|
+
) => Promise<TResponse | StreamResponse | ErrorResponse>;
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Basic Example with `fetch`
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { createClient } from './client.js';
|
|
63
|
+
import { ApiError, RequesterFailError, ErrorResponse } from './contracts.js';
|
|
64
|
+
|
|
65
|
+
const baseUrl = 'https://api.example.com';
|
|
66
|
+
|
|
67
|
+
const requester: Requester = async (method, path, options) => {
|
|
68
|
+
const url = new URL(path, baseUrl);
|
|
69
|
+
if (options.query) {
|
|
70
|
+
Object.entries(options.query).forEach(([key, value]) => {
|
|
71
|
+
url.searchParams.set(key, String(value));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const response = await fetch(url, {
|
|
76
|
+
method,
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
...options.headers,
|
|
80
|
+
},
|
|
81
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
return new ErrorResponse(
|
|
86
|
+
response.status,
|
|
87
|
+
await response.json(),
|
|
88
|
+
response.headers,
|
|
89
|
+
response.statusText
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return response.json();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const client = createClient(requester);
|
|
97
|
+
|
|
98
|
+
// Typed call — response type is inferred from the spec
|
|
99
|
+
const pets = await client.getPets({ limit: 10 });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
See [Binary / File Responses](#binary--file-responses) for handling `expectStream: true`.
|
|
103
|
+
|
|
104
|
+
## Binary / File Responses
|
|
105
|
+
|
|
106
|
+
When your spec defines binary responses (e.g. `format: binary`,
|
|
107
|
+
`application/octet-stream`, `image/*`), the generated client sends
|
|
108
|
+
`expectStream: true` in options. Your `Requester` should return a
|
|
109
|
+
`StreamResponse` in that case:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
import { StreamResponse } from './contracts.js';
|
|
113
|
+
|
|
114
|
+
// Inside your Requester implementation:
|
|
115
|
+
if (options.expectStream === true) {
|
|
116
|
+
return new StreamResponse(
|
|
117
|
+
response.body as ReadableStream<Uint8Array>,
|
|
118
|
+
getFilename(response.headers), // extract from Content-Disposition
|
|
119
|
+
response.headers
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`StreamResponse` is a simple container:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
class StreamResponse {
|
|
128
|
+
data: ReadableStream<Uint8Array>;
|
|
129
|
+
filename?: string;
|
|
130
|
+
headers: Headers;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Response Helpers
|
|
135
|
+
|
|
136
|
+
The generated `contracts.ts` includes helper functions for constructing
|
|
137
|
+
responses in your `Requester` implementation:
|
|
138
|
+
|
|
139
|
+
- `streamResponse(data, filename?, headers?)` — Creates a `StreamResponse` instance
|
|
140
|
+
- `errorResponse(status, data, headers?, message?)` — Creates an `ErrorResponse` instance
|
|
141
|
+
|
|
142
|
+
These are convenience wrappers around the `StreamResponse` and `ErrorResponse`
|
|
143
|
+
constructors.
|
|
144
|
+
|
|
145
|
+
## CLI Reference
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
genoc <spec> [flags]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`<spec>` — Path or URL to an OpenAPI 3.0 / 3.1 spec (JSON or YAML).
|
|
152
|
+
|
|
153
|
+
| Flag | Default | Description |
|
|
154
|
+
| ------------------------ | ------------ | ---------------------------------------------------- |
|
|
155
|
+
| `--output-dir` | (required) | Output directory for generated files |
|
|
156
|
+
| `--method-name-strategy` | `path-based` | Method naming strategy |
|
|
157
|
+
| `--spec-version` | auto-detect | Override version detection (`"3.0"` or `"3.1"`) |
|
|
158
|
+
| `--strict-version` | `true` | Warn if `--spec-version` mismatches detected version |
|
|
159
|
+
|
|
160
|
+
## Method Naming Strategies
|
|
161
|
+
|
|
162
|
+
- **`path-based`** (default) — HTTP method + path segments in PascalCase.
|
|
163
|
+
`GET /pets` → `getPets`, `GET /api/v1/products` → `getApiV1Products`
|
|
164
|
+
|
|
165
|
+
- **`operationId`** — Use the `operationId` field from the spec.
|
|
166
|
+
`GET /pets` → `findPets` (if `operationId` is `"findPets"`)
|
|
167
|
+
|
|
168
|
+
- **`operationId-with-fallback`** — Use `operationId` if present, otherwise
|
|
169
|
+
fall back to path-based naming.
|
|
170
|
+
|
|
171
|
+
## Programmatic API
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { generateClient } from 'genoc';
|
|
175
|
+
|
|
176
|
+
await generateClient({
|
|
177
|
+
input: './openapi.yaml',
|
|
178
|
+
outputDir: './src/api',
|
|
179
|
+
methodNameStrategy: 'path-based',
|
|
180
|
+
specVersion: '3.1',
|
|
181
|
+
strictVersion: true,
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Error Handling
|
|
186
|
+
|
|
187
|
+
The generated client throws typed errors. Each method carries its own error
|
|
188
|
+
union, and `isDefinedError` narrows a caught error to that union:
|
|
189
|
+
|
|
190
|
+
- **`ApiError<TStatus, TData>`** — Error for a specific status code defined in the spec
|
|
191
|
+
- **`UnspecifiedApiError`** — Error for a status code not defined in the spec
|
|
192
|
+
- **`RequesterFailError`** — Wraps unexpected failures in your `Requester`
|
|
193
|
+
- **`isDefinedError(err, client.method)`** — Type guard that narrows to the method's defined error union
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { UnspecifiedApiError, RequesterFailError } from './contracts.js';
|
|
197
|
+
import { isDefinedError } from './client.js';
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const result = await client.getPets();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (isDefinedError(error, client.getPets)) {
|
|
203
|
+
// error is narrowed to GetPetsErrors (ApiError<400, ...> | ApiError<500, ...>)
|
|
204
|
+
if (error.status === 400) {
|
|
205
|
+
console.error('Bad request:', error.data);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (error instanceof UnspecifiedApiError) {
|
|
210
|
+
console.error('Unexpected status:', error.status, error.data);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (error instanceof RequesterFailError) {
|
|
214
|
+
console.error('Requester failed:', error.cause);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Feature Support
|
|
220
|
+
|
|
221
|
+
Check the detailed feature support tables to see if your OpenAPI spec features are covered:
|
|
222
|
+
|
|
223
|
+
- **[OpenAPI 3.0 Support](./docs/openapi-3.0-support.md)** — Data types, schema keywords, parameters, request bodies, file uploads, responses, error handling, `$ref` resolution, components, security schemes, servers, and path operations.
|
|
224
|
+
- **[OpenAPI 3.1 Support](./docs/openapi-3.1-support.md)** — All 3.0 features plus type arrays, `$ref` siblings, webhooks, JSON Schema 2020-12 alignment, and a [3.0 → 3.1 diff](./docs/openapi-3.1-support.md#differences-from-openapi-30).
|
|
225
|
+
|
|
226
|
+
## Requirements
|
|
227
|
+
|
|
228
|
+
- Node.js >= 18
|
|
229
|
+
- OpenAPI 3.0.x or 3.1.x specification (JSON or YAML, file path or URL)
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
[MIT](./LICENSE) — Copyright © Andrey Kiselev
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MethodNameStrategy } from '../types/client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a TypeScript method name from HTTP method and path
|
|
4
|
+
* @param method HTTP method (get, post, put, patch, delete, options, head, trace)
|
|
5
|
+
* @param path URL path
|
|
6
|
+
* @returns Generated method name
|
|
7
|
+
*/
|
|
8
|
+
export declare function generateMethodName(method: string, path: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Generate a method name from operation ID
|
|
11
|
+
* @param operationId Operation ID from OpenAPI spec
|
|
12
|
+
* @returns Generated method name in camelCase
|
|
13
|
+
*/
|
|
14
|
+
export declare function generateMethodNameFromOperationId(operationId: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Get method name based on strategy
|
|
17
|
+
* @param method HTTP method
|
|
18
|
+
* @param path URL path
|
|
19
|
+
* @param operationId Operation ID (optional)
|
|
20
|
+
* @param strategy Method naming strategy
|
|
21
|
+
* @returns Generated method name
|
|
22
|
+
* @throws Error if operationId strategy is used but no operationId provided
|
|
23
|
+
*/
|
|
24
|
+
export declare function getMethodName(method: string, path: string, operationId: string | undefined, strategy: MethodNameStrategy): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { toPascalCaseSegment } from '../utils/case.js';
|
|
2
|
+
import { sanitizeIdentifier } from '../utils/string.js';
|
|
3
|
+
import { isPathParam, extractParamName } from '../utils/url.js';
|
|
4
|
+
/**
|
|
5
|
+
* Generate a TypeScript method name from HTTP method and path
|
|
6
|
+
* @param method HTTP method (get, post, put, patch, delete, options, head, trace)
|
|
7
|
+
* @param path URL path
|
|
8
|
+
* @returns Generated method name
|
|
9
|
+
*/
|
|
10
|
+
export function generateMethodName(method, path) {
|
|
11
|
+
const lowerMethod = method.toLowerCase();
|
|
12
|
+
// Get segments and identify parameters
|
|
13
|
+
const rawSegments = path.split('/').filter((segment) => segment.length > 0);
|
|
14
|
+
const segments = [];
|
|
15
|
+
const isParam = [];
|
|
16
|
+
for (const part of rawSegments) {
|
|
17
|
+
if (isPathParam(part)) {
|
|
18
|
+
// Extract parameter name and mark as parameter
|
|
19
|
+
const paramName = extractParamName(part);
|
|
20
|
+
isParam.push(true);
|
|
21
|
+
segments.push(paramName);
|
|
22
|
+
}
|
|
23
|
+
else if (part.startsWith(':')) {
|
|
24
|
+
// Handle standalone :param format
|
|
25
|
+
const paramName = part.substring(1);
|
|
26
|
+
isParam.push(true);
|
|
27
|
+
segments.push(paramName);
|
|
28
|
+
}
|
|
29
|
+
else if (part.includes(':')) {
|
|
30
|
+
// Handle embedded : separators (e.g., Products:change-quantity, {id}:recall)
|
|
31
|
+
const subParts = part.split(':');
|
|
32
|
+
for (let i = 0; i < subParts.length; i++) {
|
|
33
|
+
const subPart = subParts[i];
|
|
34
|
+
if (subPart === '')
|
|
35
|
+
continue;
|
|
36
|
+
if (isPathParam(subPart)) {
|
|
37
|
+
isParam.push(true);
|
|
38
|
+
segments.push(extractParamName(subPart));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
isParam.push(false);
|
|
42
|
+
segments.push(subPart);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// Regular segment
|
|
48
|
+
isParam.push(false);
|
|
49
|
+
segments.push(part);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const transformedSegments = segments.map((segment, index) => {
|
|
53
|
+
if (isParam[index]) {
|
|
54
|
+
return `By${toPascalCaseSegment(segment)}`;
|
|
55
|
+
}
|
|
56
|
+
return toPascalCaseSegment(segment);
|
|
57
|
+
});
|
|
58
|
+
return lowerMethod + transformedSegments.join('');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Generate a method name from operation ID
|
|
62
|
+
* @param operationId Operation ID from OpenAPI spec
|
|
63
|
+
* @returns Generated method name in camelCase
|
|
64
|
+
*/
|
|
65
|
+
export function generateMethodNameFromOperationId(operationId) {
|
|
66
|
+
if (!operationId) {
|
|
67
|
+
throw new Error('Operation ID cannot be empty');
|
|
68
|
+
}
|
|
69
|
+
const sanitized = sanitizeIdentifier(operationId);
|
|
70
|
+
const normalizedForCamelCase = sanitized.replace(/[$]/g, '').replace(/_/g, ' ');
|
|
71
|
+
// Split into words and convert to camelCase
|
|
72
|
+
const words = normalizedForCamelCase.split(/\s+/).filter((word) => word.length > 0);
|
|
73
|
+
if (words.length === 0) {
|
|
74
|
+
return '_';
|
|
75
|
+
}
|
|
76
|
+
// Check if the first word is a reserved word
|
|
77
|
+
const firstWord = words[0];
|
|
78
|
+
const isReservedWord = ['class', 'const', 'function', 'if', 'else', 'for', 'while', 'return', 'var', 'let'].includes(firstWord.toLowerCase()) || firstWord === 'getClass';
|
|
79
|
+
let camelCased;
|
|
80
|
+
if (isReservedWord) {
|
|
81
|
+
camelCased = '_' + firstWord.charAt(0).toLowerCase() + firstWord.slice(1);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
camelCased = firstWord.charAt(0).toLowerCase() + firstWord.slice(1);
|
|
85
|
+
}
|
|
86
|
+
// Add remaining words with proper capitalization
|
|
87
|
+
camelCased += words
|
|
88
|
+
.slice(1)
|
|
89
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
90
|
+
.join('');
|
|
91
|
+
if (!camelCased || !/^[a-zA-Z_$]/.test(camelCased)) {
|
|
92
|
+
return '_' + camelCased;
|
|
93
|
+
}
|
|
94
|
+
return camelCased;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Get method name based on strategy
|
|
98
|
+
* @param method HTTP method
|
|
99
|
+
* @param path URL path
|
|
100
|
+
* @param operationId Operation ID (optional)
|
|
101
|
+
* @param strategy Method naming strategy
|
|
102
|
+
* @returns Generated method name
|
|
103
|
+
* @throws Error if operationId strategy is used but no operationId provided
|
|
104
|
+
*/
|
|
105
|
+
export function getMethodName(method, path, operationId, strategy) {
|
|
106
|
+
switch (strategy) {
|
|
107
|
+
case 'path-based':
|
|
108
|
+
return generateMethodName(method, path);
|
|
109
|
+
case 'operationId':
|
|
110
|
+
if (!operationId) {
|
|
111
|
+
throw new Error('Operation ID is required for operationId strategy but not provided');
|
|
112
|
+
}
|
|
113
|
+
return generateMethodNameFromOperationId(operationId);
|
|
114
|
+
case 'operationId-with-fallback':
|
|
115
|
+
if (operationId) {
|
|
116
|
+
return generateMethodNameFromOperationId(operationId);
|
|
117
|
+
}
|
|
118
|
+
return generateMethodName(method, path);
|
|
119
|
+
default:
|
|
120
|
+
throw new Error(`Unknown method name strategy: ${strategy}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
|
+
import type { MethodNameStrategy } from '../types/client.js';
|
|
3
|
+
import type { OpenAPIDocument, ReferenceObject, SchemaObject } from '../types/openapi.js';
|
|
4
|
+
export interface AnalyzedParameter {
|
|
5
|
+
name: string;
|
|
6
|
+
in: 'path' | 'query' | 'header' | 'cookie';
|
|
7
|
+
required: boolean;
|
|
8
|
+
schema: SchemaObject | undefined;
|
|
9
|
+
tsType: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
deprecated?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface AnalyzedRequestBody {
|
|
14
|
+
required: boolean;
|
|
15
|
+
contentTypes: string[];
|
|
16
|
+
schema: SchemaObject | ReferenceObject | undefined;
|
|
17
|
+
tsType: string;
|
|
18
|
+
isMultipart: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface AnalyzedResponse {
|
|
21
|
+
statusCode: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
schema: SchemaObject | ReferenceObject | undefined;
|
|
24
|
+
tsType: string;
|
|
25
|
+
isSuccess: boolean;
|
|
26
|
+
isBinary: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface AnalyzedOperation {
|
|
29
|
+
method: string;
|
|
30
|
+
path: string;
|
|
31
|
+
operationId: string | undefined;
|
|
32
|
+
methodName: string;
|
|
33
|
+
summary: string | undefined;
|
|
34
|
+
description: string | undefined;
|
|
35
|
+
deprecated: boolean;
|
|
36
|
+
tags: string[];
|
|
37
|
+
pathParams: AnalyzedParameter[];
|
|
38
|
+
queryParams: AnalyzedParameter[];
|
|
39
|
+
headerParams: AnalyzedParameter[];
|
|
40
|
+
cookieParams: AnalyzedParameter[];
|
|
41
|
+
requestBody: AnalyzedRequestBody | undefined;
|
|
42
|
+
responses: AnalyzedResponse[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Analyze all paths and operations from an OpenAPI document into structured data
|
|
46
|
+
* for code generation.
|
|
47
|
+
*
|
|
48
|
+
* @param doc - The parsed and validated OpenAPI document
|
|
49
|
+
* @param resolver - A RefResolver for resolving $ref pointers
|
|
50
|
+
* @param strategy - Method naming strategy (defaults to 'path-based')
|
|
51
|
+
* @returns Array of AnalyzedOperation objects
|
|
52
|
+
*/
|
|
53
|
+
export declare function analyzePaths(doc: OpenAPIDocument, resolver: RefResolver, strategy?: MethodNameStrategy): AnalyzedOperation[];
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
|
+
import { getMethodName } from './naming.js';
|
|
3
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
|
4
|
+
function isRef(obj) {
|
|
5
|
+
return obj !== null && typeof obj === 'object' && '$ref' in obj;
|
|
6
|
+
}
|
|
7
|
+
function isBinaryContentType(ct) {
|
|
8
|
+
if (ct === 'application/octet-stream')
|
|
9
|
+
return true;
|
|
10
|
+
if (ct.startsWith('image/'))
|
|
11
|
+
return true;
|
|
12
|
+
if (ct.startsWith('video/'))
|
|
13
|
+
return true;
|
|
14
|
+
if (ct.startsWith('audio/'))
|
|
15
|
+
return true;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
function schemaToTsType(schema, resolver) {
|
|
19
|
+
if (!schema)
|
|
20
|
+
return 'unknown';
|
|
21
|
+
if (isRef(schema)) {
|
|
22
|
+
const resolved = resolver.resolve(schema);
|
|
23
|
+
const refStr = schema.$ref;
|
|
24
|
+
const lastSegment = refStr.split('/').pop();
|
|
25
|
+
if (lastSegment && resolved.type) {
|
|
26
|
+
return lastSegment;
|
|
27
|
+
}
|
|
28
|
+
return lastSegment ?? 'unknown';
|
|
29
|
+
}
|
|
30
|
+
const s = schema;
|
|
31
|
+
if (s.type === undefined)
|
|
32
|
+
return 'unknown';
|
|
33
|
+
if (Array.isArray(s.type)) {
|
|
34
|
+
const nonNull = s.type.filter((t) => t !== 'null');
|
|
35
|
+
if (nonNull.length === 0)
|
|
36
|
+
return 'null';
|
|
37
|
+
return schemaToTsType({ ...s, type: nonNull[0] }, resolver);
|
|
38
|
+
}
|
|
39
|
+
switch (s.type) {
|
|
40
|
+
case 'string':
|
|
41
|
+
return 'string';
|
|
42
|
+
case 'integer':
|
|
43
|
+
case 'number':
|
|
44
|
+
return 'number';
|
|
45
|
+
case 'boolean':
|
|
46
|
+
return 'boolean';
|
|
47
|
+
case 'array':
|
|
48
|
+
if (s.items) {
|
|
49
|
+
const itemType = schemaToTsType(s.items, resolver);
|
|
50
|
+
return `${itemType}[]`;
|
|
51
|
+
}
|
|
52
|
+
return 'unknown[]';
|
|
53
|
+
case 'object':
|
|
54
|
+
return 'object';
|
|
55
|
+
case 'null':
|
|
56
|
+
return 'null';
|
|
57
|
+
default:
|
|
58
|
+
return 'unknown';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function resolveParameter(param, resolver) {
|
|
62
|
+
return resolver.resolve(param);
|
|
63
|
+
}
|
|
64
|
+
function resolveRequestBody(body, resolver) {
|
|
65
|
+
return resolver.resolve(body);
|
|
66
|
+
}
|
|
67
|
+
function resolveResponse(response, resolver) {
|
|
68
|
+
return resolver.resolve(response);
|
|
69
|
+
}
|
|
70
|
+
function analyzeParameter(param, resolver) {
|
|
71
|
+
const schema = param.schema ? resolver.resolve(param.schema) : undefined;
|
|
72
|
+
return {
|
|
73
|
+
name: param.name,
|
|
74
|
+
in: param.in,
|
|
75
|
+
required: param.required ?? param.in === 'path',
|
|
76
|
+
schema,
|
|
77
|
+
tsType: schemaToTsType(param.schema, resolver),
|
|
78
|
+
description: param.description,
|
|
79
|
+
deprecated: param.deprecated,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function mergeParameters(pathItemParams, operationParams, resolver) {
|
|
83
|
+
const resolvedPathParams = (pathItemParams ?? []).map((p) => resolveParameter(p, resolver));
|
|
84
|
+
const resolvedOpParams = (operationParams ?? []).map((p) => resolveParameter(p, resolver));
|
|
85
|
+
const opParamMap = new Map();
|
|
86
|
+
for (const p of resolvedOpParams) {
|
|
87
|
+
opParamMap.set(`${p.name}::${p.in}`, p);
|
|
88
|
+
}
|
|
89
|
+
const merged = [];
|
|
90
|
+
for (const p of resolvedPathParams) {
|
|
91
|
+
const key = `${p.name}::${p.in}`;
|
|
92
|
+
if (!opParamMap.has(key)) {
|
|
93
|
+
merged.push(p);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const p of resolvedOpParams) {
|
|
97
|
+
merged.push(p);
|
|
98
|
+
}
|
|
99
|
+
return merged;
|
|
100
|
+
}
|
|
101
|
+
function analyzeRequestBody(body, resolver) {
|
|
102
|
+
if (!body)
|
|
103
|
+
return undefined;
|
|
104
|
+
const resolved = resolveRequestBody(body, resolver);
|
|
105
|
+
const contentTypes = Object.keys(resolved.content);
|
|
106
|
+
let schema;
|
|
107
|
+
let tsType = 'unknown';
|
|
108
|
+
if (contentTypes.length > 0) {
|
|
109
|
+
const firstContent = resolved.content[contentTypes[0]];
|
|
110
|
+
if (firstContent?.schema) {
|
|
111
|
+
schema = firstContent.schema;
|
|
112
|
+
tsType = schemaToTsType(schema, resolver);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const isMultipart = contentTypes.length > 0 && contentTypes[0] === 'multipart/form-data';
|
|
116
|
+
return {
|
|
117
|
+
required: resolved.required ?? false,
|
|
118
|
+
contentTypes,
|
|
119
|
+
schema,
|
|
120
|
+
tsType,
|
|
121
|
+
isMultipart,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function analyzeResponses(responses, resolver) {
|
|
125
|
+
const result = [];
|
|
126
|
+
for (const [statusCode, response] of Object.entries(responses)) {
|
|
127
|
+
const resolved = resolveResponse(response, resolver);
|
|
128
|
+
let schema;
|
|
129
|
+
let tsType = 'unknown';
|
|
130
|
+
let contentTypes = [];
|
|
131
|
+
if (resolved.content) {
|
|
132
|
+
contentTypes = Object.keys(resolved.content);
|
|
133
|
+
if (contentTypes.length > 0) {
|
|
134
|
+
const firstContent = resolved.content[contentTypes[0]];
|
|
135
|
+
if (firstContent?.schema) {
|
|
136
|
+
schema = firstContent.schema;
|
|
137
|
+
tsType = schemaToTsType(schema, resolver);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Empty-body success responses → void
|
|
142
|
+
if (tsType === 'unknown' &&
|
|
143
|
+
statusCode.startsWith('2') &&
|
|
144
|
+
(!resolved.content || contentTypes.length === 0)) {
|
|
145
|
+
tsType = 'void';
|
|
146
|
+
}
|
|
147
|
+
const isBinary = contentTypes.length > 0 && isBinaryContentType(contentTypes[0]);
|
|
148
|
+
result.push({
|
|
149
|
+
statusCode,
|
|
150
|
+
description: resolved.description,
|
|
151
|
+
schema,
|
|
152
|
+
tsType,
|
|
153
|
+
isSuccess: statusCode.startsWith('2'),
|
|
154
|
+
isBinary,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
function categorizeParameters(params) {
|
|
160
|
+
const pathParams = [];
|
|
161
|
+
const queryParams = [];
|
|
162
|
+
const headerParams = [];
|
|
163
|
+
const cookieParams = [];
|
|
164
|
+
for (const param of params) {
|
|
165
|
+
switch (param.in) {
|
|
166
|
+
case 'path':
|
|
167
|
+
pathParams.push(param);
|
|
168
|
+
break;
|
|
169
|
+
case 'query':
|
|
170
|
+
queryParams.push(param);
|
|
171
|
+
break;
|
|
172
|
+
case 'header':
|
|
173
|
+
headerParams.push(param);
|
|
174
|
+
break;
|
|
175
|
+
case 'cookie':
|
|
176
|
+
cookieParams.push(param);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { pathParams, queryParams, headerParams, cookieParams };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Analyze all paths and operations from an OpenAPI document into structured data
|
|
184
|
+
* for code generation.
|
|
185
|
+
*
|
|
186
|
+
* @param doc - The parsed and validated OpenAPI document
|
|
187
|
+
* @param resolver - A RefResolver for resolving $ref pointers
|
|
188
|
+
* @param strategy - Method naming strategy (defaults to 'path-based')
|
|
189
|
+
* @returns Array of AnalyzedOperation objects
|
|
190
|
+
*/
|
|
191
|
+
export function analyzePaths(doc, resolver, strategy = 'path-based') {
|
|
192
|
+
const operations = [];
|
|
193
|
+
if (!doc.paths)
|
|
194
|
+
return operations;
|
|
195
|
+
for (const [urlPath, pathItem] of Object.entries(doc.paths)) {
|
|
196
|
+
for (const method of HTTP_METHODS) {
|
|
197
|
+
const operation = pathItem[method];
|
|
198
|
+
if (!operation)
|
|
199
|
+
continue;
|
|
200
|
+
const mergedParams = mergeParameters(pathItem.parameters, operation.parameters, resolver);
|
|
201
|
+
const analyzedParams = mergedParams.map((p) => analyzeParameter(p, resolver));
|
|
202
|
+
const categorized = categorizeParameters(analyzedParams);
|
|
203
|
+
const requestBody = analyzeRequestBody(operation.requestBody, resolver);
|
|
204
|
+
const responses = analyzeResponses(operation.responses, resolver);
|
|
205
|
+
const methodName = getMethodName(method, urlPath, operation.operationId, strategy);
|
|
206
|
+
operations.push({
|
|
207
|
+
method,
|
|
208
|
+
path: urlPath,
|
|
209
|
+
operationId: operation.operationId,
|
|
210
|
+
methodName,
|
|
211
|
+
summary: operation.summary,
|
|
212
|
+
description: operation.description,
|
|
213
|
+
deprecated: operation.deprecated ?? false,
|
|
214
|
+
tags: operation.tags ?? [],
|
|
215
|
+
...categorized,
|
|
216
|
+
requestBody,
|
|
217
|
+
responses,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return operations;
|
|
222
|
+
}
|