genoc 0.1.2 → 0.2.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 +56 -26
- package/dist/analyzer/path-analyzer.d.ts +2 -0
- package/dist/analyzer/path-analyzer.js +4 -1
- package/dist/analyzer/schema-mapper.d.ts +8 -0
- package/dist/analyzer/schema-mapper.js +67 -30
- package/dist/cli/app.d.ts +1 -0
- package/dist/cli/app.js +7 -0
- package/dist/cli/impl.js +1 -0
- package/dist/generator/client-generator.js +9 -44
- package/dist/generator/contracts-generator.d.ts +2 -3
- package/dist/generator/contracts-generator.js +151 -141
- package/dist/generator/error-types.d.ts +4 -15
- package/dist/generator/error-types.js +4 -31
- package/dist/generator/method-generator.js +11 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/runtime/defined-errors.d.ts +17 -0
- package/dist/runtime/defined-errors.js +27 -0
- package/dist/runtime/errors.d.ts +28 -0
- package/dist/runtime/errors.js +45 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.js +10 -0
- package/dist/runtime/requester.d.ts +14 -0
- package/dist/runtime/requester.js +1 -0
- package/dist/runtime/responses.d.ts +19 -0
- package/dist/runtime/responses.js +28 -0
- package/dist/types/client.d.ts +1 -0
- package/dist/types/openapi.d.ts +4 -0
- package/dist/utils/generator-helpers.d.ts +52 -0
- package/dist/utils/generator-helpers.js +126 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# genoc
|
|
2
2
|
|
|
3
3
|
Generate TypeScript HTTP clients from OpenAPI 3.0 / 3.1 specifications.
|
|
4
|
-
|
|
4
|
+
Generated code depends only on the tiny `genoc/runtime` module. Full type safety. Bring your own HTTP client.
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/genoc)
|
|
7
7
|
[](https://nodejs.org/)
|
|
@@ -13,6 +13,7 @@ The generated code has zero runtime dependencies. Full type safety. Bring your o
|
|
|
13
13
|
- Full OpenAPI 3.0 and 3.1 specification support with automatic version detection
|
|
14
14
|
- End-to-end type safety — requests, responses, and errors are fully typed
|
|
15
15
|
- HTTP-client agnostic — adapter pattern lets you plug in fetch, axios, or anything else
|
|
16
|
+
- Shared runtime contract — `genoc/runtime` exports the `Requester` type and response/error classes, so one requester implementation works with every generated client
|
|
16
17
|
- Error types with per-status-code narrowing and type guards
|
|
17
18
|
- File and binary upload/download with stream handling
|
|
18
19
|
- Flexible method naming strategies (path-based, operationId, operationId-with-fallback)
|
|
@@ -23,9 +24,12 @@ The generated code has zero runtime dependencies. Full type safety. Bring your o
|
|
|
23
24
|
Install:
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
|
-
npm install
|
|
27
|
+
npm install genoc
|
|
27
28
|
```
|
|
28
29
|
|
|
30
|
+
Generated code imports from `genoc/runtime`, so `genoc` is a runtime
|
|
31
|
+
dependency (not just `devDependencies`).
|
|
32
|
+
|
|
29
33
|
Generate:
|
|
30
34
|
|
|
31
35
|
```bash
|
|
@@ -40,8 +44,13 @@ This creates two files in `./src/api`:
|
|
|
40
44
|
## Usage
|
|
41
45
|
|
|
42
46
|
The generated client requires a `Requester` implementation — a function that
|
|
43
|
-
performs the actual HTTP call and returns the result.
|
|
44
|
-
|
|
47
|
+
performs the actual HTTP call and returns the result. The type lives in
|
|
48
|
+
`genoc/runtime`, so you can write and compile a requester before generating
|
|
49
|
+
anything:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import type { Requester } from 'genoc/runtime';
|
|
53
|
+
```
|
|
45
54
|
|
|
46
55
|
```typescript
|
|
47
56
|
type Requester = <TResponse>(
|
|
@@ -60,7 +69,8 @@ type Requester = <TResponse>(
|
|
|
60
69
|
|
|
61
70
|
```typescript
|
|
62
71
|
import { createClient } from './client.js';
|
|
63
|
-
import {
|
|
72
|
+
import type { Requester } from 'genoc/runtime';
|
|
73
|
+
import { RequesterFailError, ErrorResponse } from 'genoc/runtime';
|
|
64
74
|
|
|
65
75
|
const baseUrl = 'https://api.example.com';
|
|
66
76
|
|
|
@@ -85,7 +95,7 @@ const requester: Requester = async (method, path, options) => {
|
|
|
85
95
|
return new ErrorResponse(
|
|
86
96
|
response.status,
|
|
87
97
|
await response.json(),
|
|
88
|
-
response.headers,
|
|
98
|
+
Object.fromEntries(response.headers.entries()),
|
|
89
99
|
response.statusText
|
|
90
100
|
);
|
|
91
101
|
}
|
|
@@ -101,6 +111,27 @@ const pets = await client.getPets({ limit: 10 });
|
|
|
101
111
|
|
|
102
112
|
See [Binary / File Responses](#binary--file-responses) for handling `expectStream: true`.
|
|
103
113
|
|
|
114
|
+
## Shared Runtime (`genoc/runtime`)
|
|
115
|
+
|
|
116
|
+
Generated clients import their response and error classes from `genoc/runtime`
|
|
117
|
+
instead of declaring inline copies (the generated `contracts.ts` re-exports
|
|
118
|
+
them, so existing imports keep working). This gives every generated client the
|
|
119
|
+
same class identity — `instanceof` checks work across clients, and you can
|
|
120
|
+
implement **one shared requester** typed against the package:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// common-requester.ts — reusable across all generated clients,
|
|
124
|
+
// no generated imports needed
|
|
125
|
+
import type { Requester } from 'genoc/runtime';
|
|
126
|
+
|
|
127
|
+
export const requester: Requester = async (method, path, options) => {
|
|
128
|
+
// ...your fetch/axios/etc. implementation
|
|
129
|
+
};
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
To pin a specific version or point at a mirror, override the import specifier
|
|
133
|
+
via `--runtime-import-path` (CLI) or `runtimeImportPath` (programmatic config).
|
|
134
|
+
|
|
104
135
|
## Binary / File Responses
|
|
105
136
|
|
|
106
137
|
When your spec defines binary responses (e.g. `format: binary`,
|
|
@@ -109,14 +140,14 @@ When your spec defines binary responses (e.g. `format: binary`,
|
|
|
109
140
|
`StreamResponse` in that case:
|
|
110
141
|
|
|
111
142
|
```typescript
|
|
112
|
-
import { StreamResponse } from '
|
|
143
|
+
import { StreamResponse } from 'genoc/runtime';
|
|
113
144
|
|
|
114
145
|
// Inside your Requester implementation:
|
|
115
146
|
if (options.expectStream === true) {
|
|
116
147
|
return new StreamResponse(
|
|
117
148
|
response.body as ReadableStream<Uint8Array>,
|
|
118
149
|
getFilename(response.headers), // extract from Content-Disposition
|
|
119
|
-
response.headers
|
|
150
|
+
Object.fromEntries(response.headers.entries())
|
|
120
151
|
);
|
|
121
152
|
}
|
|
122
153
|
```
|
|
@@ -131,17 +162,6 @@ class StreamResponse {
|
|
|
131
162
|
}
|
|
132
163
|
```
|
|
133
164
|
|
|
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
165
|
## CLI Reference
|
|
146
166
|
|
|
147
167
|
```bash
|
|
@@ -150,12 +170,13 @@ genoc <spec> [flags]
|
|
|
150
170
|
|
|
151
171
|
`<spec>` — Path or URL to an OpenAPI 3.0 / 3.1 spec (JSON or YAML).
|
|
152
172
|
|
|
153
|
-
| Flag | Default
|
|
154
|
-
| ------------------------ |
|
|
155
|
-
| `--output-dir` | (required)
|
|
156
|
-
| `--method-name-strategy` | `path-based`
|
|
157
|
-
| `--spec-version` | auto-detect
|
|
158
|
-
| `--strict-version` | `true`
|
|
173
|
+
| Flag | Default | Description |
|
|
174
|
+
| ------------------------ | --------------- | ------------------------------------------------------------ |
|
|
175
|
+
| `--output-dir` | (required) | Output directory for generated files |
|
|
176
|
+
| `--method-name-strategy` | `path-based` | Method naming strategy |
|
|
177
|
+
| `--spec-version` | auto-detect | Override version detection (`"3.0"` or `"3.1"`) |
|
|
178
|
+
| `--strict-version` | `true` | Warn if `--spec-version` mismatches detected version |
|
|
179
|
+
| `--runtime-import-path` | `genoc/runtime` | Module specifier generated code imports runtime classes from |
|
|
159
180
|
|
|
160
181
|
## Method Naming Strategies
|
|
161
182
|
|
|
@@ -168,6 +189,15 @@ genoc <spec> [flags]
|
|
|
168
189
|
- **`operationId-with-fallback`** — Use `operationId` if present, otherwise
|
|
169
190
|
fall back to path-based naming.
|
|
170
191
|
|
|
192
|
+
## JSDoc Documentation Output
|
|
193
|
+
|
|
194
|
+
`description`, `deprecated`, `default`, `example`/`examples`, and `title`
|
|
195
|
+
fields from your spec become JSDoc comments in the generated code — on object
|
|
196
|
+
type properties, named schemas, and client methods. Object types are always
|
|
197
|
+
emitted multi-line, so per-property JSDoc stays aligned at each nesting depth.
|
|
198
|
+
`@deprecated` tags give you editor strikethrough on deprecated fields and
|
|
199
|
+
methods for free.
|
|
200
|
+
|
|
171
201
|
## Programmatic API
|
|
172
202
|
|
|
173
203
|
```typescript
|
|
@@ -194,7 +224,7 @@ union, and `isDefinedError` narrows a caught error to that union:
|
|
|
194
224
|
|
|
195
225
|
```typescript
|
|
196
226
|
import { UnspecifiedApiError, RequesterFailError } from './contracts.js';
|
|
197
|
-
import { isDefinedError } from '
|
|
227
|
+
import { isDefinedError } from 'genoc/runtime';
|
|
198
228
|
|
|
199
229
|
try {
|
|
200
230
|
const result = await client.getPets();
|
|
@@ -9,6 +9,7 @@ export interface AnalyzedParameter {
|
|
|
9
9
|
tsType: string;
|
|
10
10
|
description?: string;
|
|
11
11
|
deprecated?: boolean;
|
|
12
|
+
example?: unknown;
|
|
12
13
|
}
|
|
13
14
|
export interface AnalyzedRequestBody {
|
|
14
15
|
required: boolean;
|
|
@@ -16,6 +17,7 @@ export interface AnalyzedRequestBody {
|
|
|
16
17
|
schema: SchemaObject | ReferenceObject | undefined;
|
|
17
18
|
tsType: string;
|
|
18
19
|
isMultipart: boolean;
|
|
20
|
+
description?: string;
|
|
19
21
|
}
|
|
20
22
|
export interface AnalyzedResponse {
|
|
21
23
|
statusCode: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
|
+
import { sanitizeTypeName } from '../utils/generator-helpers.js';
|
|
2
3
|
import { getMethodName } from './naming.js';
|
|
3
4
|
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
|
4
5
|
function isRef(obj) {
|
|
@@ -21,7 +22,7 @@ function schemaToTsType(schema, resolver) {
|
|
|
21
22
|
if (isRef(schema)) {
|
|
22
23
|
const resolved = resolver.resolve(schema);
|
|
23
24
|
const refStr = schema.$ref;
|
|
24
|
-
const lastSegment = refStr.split('/').pop();
|
|
25
|
+
const lastSegment = sanitizeTypeName(refStr.split('/').pop());
|
|
25
26
|
if (lastSegment && resolved.type) {
|
|
26
27
|
return lastSegment;
|
|
27
28
|
}
|
|
@@ -77,6 +78,7 @@ function analyzeParameter(param, resolver) {
|
|
|
77
78
|
tsType: schemaToTsType(param.schema, resolver),
|
|
78
79
|
description: param.description,
|
|
79
80
|
deprecated: param.deprecated,
|
|
81
|
+
example: param.example,
|
|
80
82
|
};
|
|
81
83
|
}
|
|
82
84
|
function mergeParameters(pathItemParams, operationParams, resolver) {
|
|
@@ -119,6 +121,7 @@ function analyzeRequestBody(body, resolver) {
|
|
|
119
121
|
schema,
|
|
120
122
|
tsType,
|
|
121
123
|
isMultipart,
|
|
124
|
+
description: resolved.description,
|
|
122
125
|
};
|
|
123
126
|
}
|
|
124
127
|
function analyzeResponses(responses, resolver) {
|
|
@@ -44,5 +44,13 @@ export declare class SchemaMapper {
|
|
|
44
44
|
private mapDiscriminatedUnion;
|
|
45
45
|
private resolveDiscriminantValue;
|
|
46
46
|
private mapArray;
|
|
47
|
+
/**
|
|
48
|
+
* Render the per-property JSDoc comment lines for an object property.
|
|
49
|
+
*
|
|
50
|
+
* Metadata comes from the property's own schema node; for a `$ref` property
|
|
51
|
+
* the resolved target's own node metadata is used (one level only — never
|
|
52
|
+
* recursed), so self-referential schemas cannot loop here.
|
|
53
|
+
*/
|
|
54
|
+
private renderPropertyJsDoc;
|
|
47
55
|
private mapObject;
|
|
48
56
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
2
|
import { formatToBrandTypeName } from '../utils/case.js';
|
|
3
|
+
import { buildTypeJsDoc, sanitizeTypeName } from '../utils/generator-helpers.js';
|
|
3
4
|
import { quoteKey } from '../utils/string.js';
|
|
5
|
+
/** Indentation contract for multi-line types — 2-space unit pinned from the generated ServerParams interface; binding for downstream generators. */
|
|
6
|
+
const INDENT_UNIT = ' ';
|
|
7
|
+
function indentBy(level) {
|
|
8
|
+
return INDENT_UNIT.repeat(level);
|
|
9
|
+
}
|
|
4
10
|
/**
|
|
5
11
|
* Default: extracts the last segment of the JSON pointer.
|
|
6
12
|
* "#/components/schemas/User" -> "User"
|
|
7
13
|
*/
|
|
8
14
|
function defaultTypeNameGenerator(refString) {
|
|
9
15
|
const segments = refString.split('/');
|
|
10
|
-
|
|
16
|
+
const rawSegment = segments[segments.length - 1] || 'unknown';
|
|
17
|
+
return sanitizeTypeName(rawSegment);
|
|
11
18
|
}
|
|
12
19
|
function isRefObject(obj) {
|
|
13
20
|
return (obj !== null &&
|
|
@@ -56,7 +63,7 @@ export class SchemaMapper {
|
|
|
56
63
|
return { tsType: schema ? 'unknown' : 'never', imports: [] };
|
|
57
64
|
}
|
|
58
65
|
const visited = new Set();
|
|
59
|
-
const result = this.mapInternal(schema, name, context, visited);
|
|
66
|
+
const result = this.mapInternal(schema, name, context, visited, 0);
|
|
60
67
|
if (name && this.discriminatorTargets.has(name)) {
|
|
61
68
|
const target = this.discriminatorTargets.get(name);
|
|
62
69
|
result.tsType += ` & { ${quoteKey(target.propertyName)}: '${target.literalValue}' }`;
|
|
@@ -73,7 +80,7 @@ export class SchemaMapper {
|
|
|
73
80
|
return null;
|
|
74
81
|
return brandName;
|
|
75
82
|
}
|
|
76
|
-
mapInternal(schema, name, context, visited) {
|
|
83
|
+
mapInternal(schema, name, context, visited, indent) {
|
|
77
84
|
if (isRefObject(schema)) {
|
|
78
85
|
const refStr = schema.$ref;
|
|
79
86
|
const refName = this.typeNameGenerator(refStr);
|
|
@@ -87,7 +94,7 @@ export class SchemaMapper {
|
|
|
87
94
|
if (resolved) {
|
|
88
95
|
const discInfo = this.resolveDiscriminatorInfo(resolved, refStr);
|
|
89
96
|
if (discInfo) {
|
|
90
|
-
const expanded = this.mapInternal(resolved, undefined, context, visited);
|
|
97
|
+
const expanded = this.mapInternal(resolved, undefined, context, visited, indent);
|
|
91
98
|
expanded.tsType += ` & { ${quoteKey(discInfo.propertyName)}: '${discInfo.literalValue}' }`;
|
|
92
99
|
return expanded;
|
|
93
100
|
}
|
|
@@ -119,7 +126,7 @@ export class SchemaMapper {
|
|
|
119
126
|
return { tsType, imports: [] };
|
|
120
127
|
}
|
|
121
128
|
if (s.allOf !== undefined && s.allOf.length > 0) {
|
|
122
|
-
const result = this.mapCombinator(s.allOf, '&', context, visited);
|
|
129
|
+
const result = this.mapCombinator(s.allOf, '&', context, visited, indent);
|
|
123
130
|
if (s.nullable === true) {
|
|
124
131
|
return {
|
|
125
132
|
tsType: needsParens(result.tsType)
|
|
@@ -132,8 +139,8 @@ export class SchemaMapper {
|
|
|
132
139
|
}
|
|
133
140
|
if (s.oneOf !== undefined && s.oneOf.length > 0) {
|
|
134
141
|
const result = s.discriminator
|
|
135
|
-
? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited)
|
|
136
|
-
: this.mapCombinator(s.oneOf, '|', context, visited);
|
|
142
|
+
? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited, indent)
|
|
143
|
+
: this.mapCombinator(s.oneOf, '|', context, visited, indent);
|
|
137
144
|
if (s.nullable === true) {
|
|
138
145
|
return {
|
|
139
146
|
tsType: needsParens(result.tsType)
|
|
@@ -146,8 +153,8 @@ export class SchemaMapper {
|
|
|
146
153
|
}
|
|
147
154
|
if (s.anyOf !== undefined && s.anyOf.length > 0) {
|
|
148
155
|
const result = s.discriminator
|
|
149
|
-
? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited)
|
|
150
|
-
: this.mapCombinator(s.anyOf, '|', context, visited);
|
|
156
|
+
? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited, indent)
|
|
157
|
+
: this.mapCombinator(s.anyOf, '|', context, visited, indent);
|
|
151
158
|
if (s.nullable === true) {
|
|
152
159
|
return {
|
|
153
160
|
tsType: needsParens(result.tsType)
|
|
@@ -167,7 +174,7 @@ export class SchemaMapper {
|
|
|
167
174
|
if (nonNull.length === 0) {
|
|
168
175
|
return { tsType: 'null', imports: [] };
|
|
169
176
|
}
|
|
170
|
-
const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited));
|
|
177
|
+
const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited), indent);
|
|
171
178
|
if (hasNull) {
|
|
172
179
|
return {
|
|
173
180
|
tsType: `${baseResult.tsType} | null`,
|
|
@@ -222,15 +229,15 @@ export class SchemaMapper {
|
|
|
222
229
|
case 'null':
|
|
223
230
|
return { tsType: 'null', imports: [] };
|
|
224
231
|
case 'array':
|
|
225
|
-
return this.mapArray(s, context, visited);
|
|
232
|
+
return this.mapArray(s, context, visited, indent);
|
|
226
233
|
case 'object':
|
|
227
|
-
return this.mapObject(s, name, context, visited);
|
|
234
|
+
return this.mapObject(s, name, context, visited, indent);
|
|
228
235
|
default:
|
|
229
236
|
return { tsType: 'unknown', imports: [] };
|
|
230
237
|
}
|
|
231
238
|
}
|
|
232
239
|
resolveDiscriminatorInfo(schema, refStr) {
|
|
233
|
-
const schemaName = refStr.split('/').pop();
|
|
240
|
+
const schemaName = sanitizeTypeName(refStr.split('/').pop());
|
|
234
241
|
const target = this.discriminatorTargets.get(schemaName);
|
|
235
242
|
if (target)
|
|
236
243
|
return target;
|
|
@@ -268,8 +275,8 @@ export class SchemaMapper {
|
|
|
268
275
|
})
|
|
269
276
|
.join(' | ');
|
|
270
277
|
}
|
|
271
|
-
mapCombinator(schemas, kind, context, visited) {
|
|
272
|
-
const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited));
|
|
278
|
+
mapCombinator(schemas, kind, context, visited, indent) {
|
|
279
|
+
const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited, indent));
|
|
273
280
|
const allImports = [];
|
|
274
281
|
for (const r of results) {
|
|
275
282
|
allImports.push(...r.imports);
|
|
@@ -283,14 +290,14 @@ export class SchemaMapper {
|
|
|
283
290
|
});
|
|
284
291
|
return { tsType: parts.join(separator), imports: allImports };
|
|
285
292
|
}
|
|
286
|
-
mapDiscriminatedUnion(schemas, discriminator, context, visited) {
|
|
293
|
+
mapDiscriminatedUnion(schemas, discriminator, context, visited, indent) {
|
|
287
294
|
const propertyName = discriminator.propertyName;
|
|
288
295
|
const mapping = discriminator.mapping;
|
|
289
296
|
const allImports = [];
|
|
290
297
|
const parts = [];
|
|
291
298
|
for (const schema of schemas) {
|
|
292
299
|
const discriminantValue = this.resolveDiscriminantValue(schema, propertyName, mapping);
|
|
293
|
-
const variantResult = this.mapInternal(schema, undefined, context, visited);
|
|
300
|
+
const variantResult = this.mapInternal(schema, undefined, context, visited, indent);
|
|
294
301
|
allImports.push(...variantResult.imports);
|
|
295
302
|
const quotedProp = quoteKey(propertyName);
|
|
296
303
|
parts.push(`({ ${quotedProp}: '${discriminantValue}' } & ${variantResult.tsType})`);
|
|
@@ -321,14 +328,14 @@ export class SchemaMapper {
|
|
|
321
328
|
}
|
|
322
329
|
return 'unknown';
|
|
323
330
|
}
|
|
324
|
-
mapArray(schema, context, visited) {
|
|
331
|
+
mapArray(schema, context, visited, indent) {
|
|
325
332
|
if (!schema.items) {
|
|
326
333
|
return {
|
|
327
334
|
tsType: schema.nullable === true ? 'unknown[] | null' : 'unknown[]',
|
|
328
335
|
imports: [],
|
|
329
336
|
};
|
|
330
337
|
}
|
|
331
|
-
const itemResult = this.mapInternal(schema.items, undefined, context, visited);
|
|
338
|
+
const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent);
|
|
332
339
|
const tsType = isComplexType(itemResult.tsType)
|
|
333
340
|
? `Array<${itemResult.tsType}>`
|
|
334
341
|
: `${itemResult.tsType}[]`;
|
|
@@ -337,7 +344,33 @@ export class SchemaMapper {
|
|
|
337
344
|
imports: itemResult.imports,
|
|
338
345
|
};
|
|
339
346
|
}
|
|
340
|
-
|
|
347
|
+
/**
|
|
348
|
+
* Render the per-property JSDoc comment lines for an object property.
|
|
349
|
+
*
|
|
350
|
+
* Metadata comes from the property's own schema node; for a `$ref` property
|
|
351
|
+
* the resolved target's own node metadata is used (one level only — never
|
|
352
|
+
* recursed), so self-referential schemas cannot loop here.
|
|
353
|
+
*/
|
|
354
|
+
renderPropertyJsDoc(propSchema, indentLevel) {
|
|
355
|
+
let metaSchema;
|
|
356
|
+
if (isRefObject(propSchema)) {
|
|
357
|
+
try {
|
|
358
|
+
metaSchema = this.resolver.resolveSchema(propSchema);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
metaSchema = propSchema;
|
|
366
|
+
}
|
|
367
|
+
const block = buildTypeJsDoc(metaSchema);
|
|
368
|
+
if (block === '')
|
|
369
|
+
return [];
|
|
370
|
+
const ind = indentBy(indentLevel);
|
|
371
|
+
return block.split('\n').map((line) => `${ind}${line}`);
|
|
372
|
+
}
|
|
373
|
+
mapObject(schema, name, context, visited, indent) {
|
|
341
374
|
const properties = schema.properties ?? {};
|
|
342
375
|
const requiredSet = new Set(schema.required ?? []);
|
|
343
376
|
const propNames = Object.keys(properties);
|
|
@@ -352,7 +385,9 @@ export class SchemaMapper {
|
|
|
352
385
|
return false;
|
|
353
386
|
return true;
|
|
354
387
|
});
|
|
355
|
-
const
|
|
388
|
+
const ownIndent = indentBy(indent);
|
|
389
|
+
const memberIndent = indentBy(indent + 1);
|
|
390
|
+
const memberLines = [];
|
|
356
391
|
const allImports = [];
|
|
357
392
|
if (schema.discriminator) {
|
|
358
393
|
const discPropName = schema.discriminator.propertyName;
|
|
@@ -360,15 +395,16 @@ export class SchemaMapper {
|
|
|
360
395
|
if (discIndex !== -1) {
|
|
361
396
|
filteredPropNames.splice(discIndex, 1);
|
|
362
397
|
}
|
|
363
|
-
|
|
398
|
+
memberLines.push(`${memberIndent}"${discPropName}": string;`);
|
|
364
399
|
}
|
|
365
400
|
for (const propName of filteredPropNames) {
|
|
366
401
|
const propSchema = properties[propName];
|
|
367
|
-
const propResult = this.mapInternal(propSchema, undefined, context, visited);
|
|
402
|
+
const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1);
|
|
368
403
|
allImports.push(...propResult.imports);
|
|
404
|
+
memberLines.push(...this.renderPropertyJsDoc(propSchema, indent + 1));
|
|
369
405
|
const optional = requiredSet.has(propName) ? '' : '?';
|
|
370
406
|
const quotedName = quoteKey(propName);
|
|
371
|
-
|
|
407
|
+
memberLines.push(`${memberIndent}${quotedName}${optional}: ${propResult.tsType};`);
|
|
372
408
|
}
|
|
373
409
|
const additionalProps = schema.additionalProperties;
|
|
374
410
|
let indexSignature = null;
|
|
@@ -379,11 +415,11 @@ export class SchemaMapper {
|
|
|
379
415
|
indexSignature = '[key: string]: unknown';
|
|
380
416
|
}
|
|
381
417
|
else if (additionalProps !== undefined && typeof additionalProps === 'object') {
|
|
382
|
-
const addPropResult = this.mapInternal(additionalProps, undefined, context, visited);
|
|
418
|
+
const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1);
|
|
383
419
|
allImports.push(...addPropResult.imports);
|
|
384
420
|
indexSignature = `[key: string]: ${addPropResult.tsType}`;
|
|
385
421
|
}
|
|
386
|
-
const hasProps =
|
|
422
|
+
const hasProps = memberLines.length > 0;
|
|
387
423
|
const hasIndex = indexSignature !== null;
|
|
388
424
|
let result;
|
|
389
425
|
if (!hasProps && !hasIndex) {
|
|
@@ -398,7 +434,7 @@ export class SchemaMapper {
|
|
|
398
434
|
const valueType = indexSignature.replace('[key: string]: ', '');
|
|
399
435
|
if (name) {
|
|
400
436
|
result = {
|
|
401
|
-
tsType: `{
|
|
437
|
+
tsType: `{\n${memberIndent}${indexSignature};\n${ownIndent}}`,
|
|
402
438
|
imports: allImports,
|
|
403
439
|
};
|
|
404
440
|
}
|
|
@@ -410,16 +446,17 @@ export class SchemaMapper {
|
|
|
410
446
|
}
|
|
411
447
|
}
|
|
412
448
|
else {
|
|
413
|
-
const
|
|
449
|
+
const propsBlock = `{\n${memberLines.join('\n')}\n${ownIndent}}`;
|
|
414
450
|
if (hasIndex) {
|
|
451
|
+
const indexBlock = `{\n${memberIndent}${indexSignature};\n${ownIndent}}`;
|
|
415
452
|
result = {
|
|
416
|
-
tsType:
|
|
453
|
+
tsType: `${propsBlock} & ${indexBlock}`,
|
|
417
454
|
imports: allImports,
|
|
418
455
|
};
|
|
419
456
|
}
|
|
420
457
|
else {
|
|
421
458
|
result = {
|
|
422
|
-
tsType:
|
|
459
|
+
tsType: propsBlock,
|
|
423
460
|
imports: allImports,
|
|
424
461
|
};
|
|
425
462
|
}
|
package/dist/cli/app.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ interface Flags {
|
|
|
3
3
|
methodNameStrategy: 'path-based' | 'operationId' | 'operationId-with-fallback';
|
|
4
4
|
specVersion?: string;
|
|
5
5
|
strictVersion: boolean;
|
|
6
|
+
runtimeImportPath?: string;
|
|
6
7
|
}
|
|
7
8
|
export type AppFlags = Flags;
|
|
8
9
|
export declare const app: import("@stricli/core").Application<import("@stricli/core").CommandContext>;
|
package/dist/cli/app.js
CHANGED
|
@@ -29,6 +29,13 @@ const command = buildCommand({
|
|
|
29
29
|
default: true,
|
|
30
30
|
brief: 'Enable strict version checking',
|
|
31
31
|
},
|
|
32
|
+
runtimeImportPath: {
|
|
33
|
+
kind: 'parsed',
|
|
34
|
+
parse: String,
|
|
35
|
+
brief: 'Module specifier for runtime imports (default: genoc/runtime)',
|
|
36
|
+
optional: true,
|
|
37
|
+
placeholder: 'module',
|
|
38
|
+
},
|
|
32
39
|
},
|
|
33
40
|
positional: {
|
|
34
41
|
kind: 'tuple',
|
package/dist/cli/impl.js
CHANGED
|
@@ -27,6 +27,7 @@ export default async function (flags, spec) {
|
|
|
27
27
|
methodNameStrategy: flags.methodNameStrategy || 'path-based',
|
|
28
28
|
specVersion: flags.specVersion,
|
|
29
29
|
strictVersion: flags.strictVersion,
|
|
30
|
+
runtimeImportPath: flags.runtimeImportPath,
|
|
30
31
|
};
|
|
31
32
|
const preserveRefSiblings = strategy.version() === '3.1';
|
|
32
33
|
this.process.stdout.write('Generating client...\n');
|
|
@@ -2,7 +2,7 @@ import { mkdir, writeFile } from 'fs/promises';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { analyzePaths } from '../analyzer/path-analyzer.js';
|
|
4
4
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
5
|
-
import { getOperationTypePrefix, getSuccessType, getErrorType, makeHeader, } from '../utils/generator-helpers.js';
|
|
5
|
+
import { DEFAULT_RUNTIME_IMPORT_PATH, getOperationTypePrefix, getSuccessType, getErrorType, makeHeader, } from '../utils/generator-helpers.js';
|
|
6
6
|
import { generateContracts } from './contracts-generator.js';
|
|
7
7
|
import { generateMethod } from './method-generator.js';
|
|
8
8
|
function collectImportTypes(operations) {
|
|
@@ -156,7 +156,7 @@ function buildClientMethodBody(op) {
|
|
|
156
156
|
}
|
|
157
157
|
return tryCatchLines.join('\n');
|
|
158
158
|
}
|
|
159
|
-
function buildClientFile(operations, version) {
|
|
159
|
+
function buildClientFile(operations, version, runtimeImportPath = DEFAULT_RUNTIME_IMPORT_PATH) {
|
|
160
160
|
const lines = [];
|
|
161
161
|
lines.push(makeHeader(version));
|
|
162
162
|
const importTypes = collectImportTypes(operations);
|
|
@@ -180,47 +180,11 @@ function buildClientFile(operations, version) {
|
|
|
180
180
|
if (hasMultipart) {
|
|
181
181
|
lines.push('/* global FormData */');
|
|
182
182
|
}
|
|
183
|
+
lines.push(`import { decorateWithErrors } from '${runtimeImportPath}';`);
|
|
184
|
+
lines.push(`import type { Requester } from '${runtimeImportPath}';`);
|
|
183
185
|
lines.push('');
|
|
184
|
-
lines.push(
|
|
185
|
-
lines.push(
|
|
186
|
-
lines.push(' runtimeErrors: unknown,');
|
|
187
|
-
lines.push('): T & { __definedErrors: E } {');
|
|
188
|
-
lines.push(' Object.defineProperty(item, "__definedErrors", {');
|
|
189
|
-
lines.push(' value: runtimeErrors,');
|
|
190
|
-
lines.push(' enumerable: false,');
|
|
191
|
-
lines.push(' configurable: true,');
|
|
192
|
-
lines.push(' writable: false,');
|
|
193
|
-
lines.push(' });');
|
|
194
|
-
lines.push(' return item as T & { __definedErrors: E };');
|
|
195
|
-
lines.push('}');
|
|
196
|
-
lines.push('');
|
|
197
|
-
lines.push('export function isDefinedError<E extends ApiError<number, unknown>>(');
|
|
198
|
-
lines.push(' err: unknown,');
|
|
199
|
-
lines.push(' fn: { __definedErrors: E },');
|
|
200
|
-
lines.push('): err is E {');
|
|
201
|
-
lines.push(' if (err instanceof UnspecifiedApiError) return false;');
|
|
202
|
-
lines.push(' if (!(err instanceof ApiError)) return false;');
|
|
203
|
-
lines.push(' return true;');
|
|
204
|
-
lines.push('}');
|
|
205
|
-
lines.push('');
|
|
206
|
-
lines.push('/**');
|
|
207
|
-
lines.push(' * Performs an HTTP request and returns the response.');
|
|
208
|
-
lines.push(' *');
|
|
209
|
-
lines.push(' * When `expectStream` is true, the implementation should return');
|
|
210
|
-
lines.push(' * a `StreamResponse` containing the stream data, filename (from');
|
|
211
|
-
lines.push(' * Content-Disposition header), and response headers.');
|
|
212
|
-
lines.push(' */');
|
|
213
|
-
lines.push('export type Requester = <TResponse>(');
|
|
214
|
-
lines.push(' method: string,');
|
|
215
|
-
lines.push(' path: string,');
|
|
216
|
-
lines.push(' options: {');
|
|
217
|
-
lines.push(' query?: Record<string, unknown>;');
|
|
218
|
-
lines.push(' body?: unknown;');
|
|
219
|
-
lines.push(' headers?: Record<string, string>;');
|
|
220
|
-
lines.push(' expectStream?: true;');
|
|
221
|
-
lines.push(' },');
|
|
222
|
-
lines.push(') => Promise<TResponse | StreamResponse | ErrorResponse>;');
|
|
223
|
-
lines.push('');
|
|
186
|
+
lines.push(`export { isDefinedError } from '${runtimeImportPath}';`);
|
|
187
|
+
lines.push(`export type { Requester } from '${runtimeImportPath}';`);
|
|
224
188
|
lines.push('');
|
|
225
189
|
lines.push('export function createClient(requester: Requester) {');
|
|
226
190
|
lines.push(' return {');
|
|
@@ -269,9 +233,10 @@ export function generateClient(doc, config, options) {
|
|
|
269
233
|
const resolver = new RefResolver(doc, undefined, {
|
|
270
234
|
preserveRefSiblings: options?.preserveRefSiblings,
|
|
271
235
|
});
|
|
272
|
-
const
|
|
236
|
+
const runtimeImportPath = config.runtimeImportPath ?? DEFAULT_RUNTIME_IMPORT_PATH;
|
|
237
|
+
const contracts = generateContracts(doc, resolver, runtimeImportPath);
|
|
273
238
|
const operations = analyzePaths(doc, resolver, config.methodNameStrategy ?? 'path-based');
|
|
274
|
-
const client = buildClientFile(operations, doc.openapi);
|
|
239
|
+
const client = buildClientFile(operations, doc.openapi, runtimeImportPath);
|
|
275
240
|
return { contracts, client };
|
|
276
241
|
}
|
|
277
242
|
/**
|
|
@@ -10,7 +10,6 @@ import type { OpenAPIDocument } from '../types/openapi.js';
|
|
|
10
10
|
* 4. Header parameter types per operation
|
|
11
11
|
* 5. Request body types per operation
|
|
12
12
|
* 6. Response / error types per operation
|
|
13
|
-
* 7.
|
|
14
|
-
* 7b. UnspecifiedApiError class
|
|
13
|
+
* 7. Runtime re-export block (shared classes from the genoc runtime package)
|
|
15
14
|
*/
|
|
16
|
-
export declare function generateContracts(doc: OpenAPIDocument, resolver: RefResolver): string;
|
|
15
|
+
export declare function generateContracts(doc: OpenAPIDocument, resolver: RefResolver, runtimeImportPath?: string): string;
|