genoc 0.2.0 → 0.3.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 +30 -7
- package/dist/analyzer/naming.js +3 -2
- package/dist/analyzer/path-analyzer.d.ts +2 -0
- package/dist/analyzer/path-analyzer.js +29 -1
- package/dist/analyzer/schema-mapper.js +5 -1
- package/dist/cli/app.d.ts +1 -0
- package/dist/cli/app.js +7 -0
- package/dist/cli/impl.js +10 -1
- package/dist/generator/contracts-generator.js +25 -16
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/parser/spec-reader.d.ts +3 -2
- package/dist/parser/spec-reader.js +5 -4
- package/dist/types/client.d.ts +1 -0
- package/dist/utils/generator-helpers.d.ts +26 -5
- package/dist/utils/generator-helpers.js +146 -11
- package/dist/utils/proxy.d.ts +6 -0
- package/dist/utils/proxy.js +81 -0
- package/package.json +2 -1
- package/dist/generator/error-types.d.ts +0 -13
- package/dist/generator/error-types.js +0 -67
package/README.md
CHANGED
|
@@ -170,13 +170,14 @@ genoc <spec> [flags]
|
|
|
170
170
|
|
|
171
171
|
`<spec>` — Path or URL to an OpenAPI 3.0 / 3.1 spec (JSON or YAML).
|
|
172
172
|
|
|
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
|
|
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 |
|
|
180
|
+
| `--proxy` | (none) | HTTP(S) proxy URL for fetching specs from URLs; overrides HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars |
|
|
180
181
|
|
|
181
182
|
## Method Naming Strategies
|
|
182
183
|
|
|
@@ -189,6 +190,27 @@ genoc <spec> [flags]
|
|
|
189
190
|
- **`operationId-with-fallback`** — Use `operationId` if present, otherwise
|
|
190
191
|
fall back to path-based naming.
|
|
191
192
|
|
|
193
|
+
## Proxy Support
|
|
194
|
+
|
|
195
|
+
When fetching a spec from a URL, `genoc` can route the request through an
|
|
196
|
+
HTTP(S) proxy:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
genoc https://api.example.com/openapi.yaml --output-dir ./src/api \
|
|
200
|
+
--proxy http://user:pass@proxy.example.com:8080
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- Credentials are supported in the proxy URL (`user:pass@host:port`).
|
|
204
|
+
- Without `--proxy`, the `HTTP_PROXY` / `HTTPS_PROXY` environment variables
|
|
205
|
+
(either casing) are respected automatically, minus `NO_PROXY` exclusions.
|
|
206
|
+
- An explicit `--proxy` flag overrides environment detection entirely.
|
|
207
|
+
- Programmatic equivalent: `proxy: 'http://proxy.example.com:8080'` in the
|
|
208
|
+
`generateClient` config, or an optional second argument `loadSpec(url, { proxy })`.
|
|
209
|
+
- SOCKS proxies and `ALL_PROXY` are not supported.
|
|
210
|
+
|
|
211
|
+
Note: if you already have `HTTP_PROXY` set in your environment, spec fetches
|
|
212
|
+
now go through it (previously ignored). Use `NO_PROXY` to opt out.
|
|
213
|
+
|
|
192
214
|
## JSDoc Documentation Output
|
|
193
215
|
|
|
194
216
|
`description`, `deprecated`, `default`, `example`/`examples`, and `title`
|
|
@@ -209,6 +231,7 @@ await generateClient({
|
|
|
209
231
|
methodNameStrategy: 'path-based',
|
|
210
232
|
specVersion: '3.1',
|
|
211
233
|
strictVersion: true,
|
|
234
|
+
proxy: 'http://proxy.example.com:8080',
|
|
212
235
|
});
|
|
213
236
|
```
|
|
214
237
|
|
package/dist/analyzer/naming.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { toPascalCaseSegment } from '../utils/case.js';
|
|
2
|
+
import { sanitizeTypeName } from '../utils/generator-helpers.js';
|
|
2
3
|
import { sanitizeIdentifier } from '../utils/string.js';
|
|
3
4
|
import { isPathParam, extractParamName } from '../utils/url.js';
|
|
4
5
|
/**
|
|
@@ -51,9 +52,9 @@ export function generateMethodName(method, path) {
|
|
|
51
52
|
}
|
|
52
53
|
const transformedSegments = segments.map((segment, index) => {
|
|
53
54
|
if (isParam[index]) {
|
|
54
|
-
return `By${toPascalCaseSegment(segment)}`;
|
|
55
|
+
return `By${sanitizeTypeName(toPascalCaseSegment(segment))}`;
|
|
55
56
|
}
|
|
56
|
-
return toPascalCaseSegment(segment);
|
|
57
|
+
return sanitizeTypeName(toPascalCaseSegment(segment));
|
|
57
58
|
});
|
|
58
59
|
return lowerMethod + transformedSegments.join('');
|
|
59
60
|
}
|
|
@@ -32,6 +32,8 @@ export interface AnalyzedOperation {
|
|
|
32
32
|
path: string;
|
|
33
33
|
operationId: string | undefined;
|
|
34
34
|
methodName: string;
|
|
35
|
+
/** Deduped by analyzePaths; computed from method + path when absent. */
|
|
36
|
+
typePrefix?: string;
|
|
35
37
|
summary: string | undefined;
|
|
36
38
|
description: string | undefined;
|
|
37
39
|
deprecated: boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RefResolver } from '../parser/ref-resolver.js';
|
|
2
|
-
import { sanitizeTypeName } from '../utils/generator-helpers.js';
|
|
2
|
+
import { sanitizeTypeName, getOperationTypePrefix } from '../utils/generator-helpers.js';
|
|
3
3
|
import { getMethodName } from './naming.js';
|
|
4
4
|
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
|
5
5
|
function isRef(obj) {
|
|
@@ -195,6 +195,8 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
|
|
|
195
195
|
const operations = [];
|
|
196
196
|
if (!doc.paths)
|
|
197
197
|
return operations;
|
|
198
|
+
const usedTypePrefixes = new Set();
|
|
199
|
+
const usedMethodNames = new Set();
|
|
198
200
|
for (const [urlPath, pathItem] of Object.entries(doc.paths)) {
|
|
199
201
|
for (const method of HTTP_METHODS) {
|
|
200
202
|
const operation = pathItem[method];
|
|
@@ -221,5 +223,31 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
|
|
|
221
223
|
});
|
|
222
224
|
}
|
|
223
225
|
}
|
|
226
|
+
// Dedupe type prefixes and method names so that distinct routes folding to
|
|
227
|
+
// the same identifier (e.g. "/weird" and "/weird-", or "/Weird" and
|
|
228
|
+
// "/weird") cannot produce duplicate exported types or client methods.
|
|
229
|
+
// NOTE: analyzePaths runs twice per generation (contracts pass + client
|
|
230
|
+
// pass); this assignment must stay deterministic - it relies on insertion
|
|
231
|
+
// order and the fixed HTTP_METHODS loop - so both passes agree.
|
|
232
|
+
for (const op of operations) {
|
|
233
|
+
const baseTypePrefix = getOperationTypePrefix(op);
|
|
234
|
+
let typePrefix = baseTypePrefix;
|
|
235
|
+
let n = 2;
|
|
236
|
+
while (usedTypePrefixes.has(typePrefix)) {
|
|
237
|
+
typePrefix = `${baseTypePrefix}${n}`;
|
|
238
|
+
n += 1;
|
|
239
|
+
}
|
|
240
|
+
usedTypePrefixes.add(typePrefix);
|
|
241
|
+
op.typePrefix = typePrefix;
|
|
242
|
+
const baseMethodName = op.methodName;
|
|
243
|
+
let methodName = baseMethodName;
|
|
244
|
+
n = 2;
|
|
245
|
+
while (usedMethodNames.has(methodName)) {
|
|
246
|
+
methodName = `${baseMethodName}${n}`;
|
|
247
|
+
n += 1;
|
|
248
|
+
}
|
|
249
|
+
usedMethodNames.add(methodName);
|
|
250
|
+
op.methodName = methodName;
|
|
251
|
+
}
|
|
224
252
|
return operations;
|
|
225
253
|
}
|
|
@@ -42,6 +42,10 @@ export class SchemaMapper {
|
|
|
42
42
|
brandedTypes = new Map();
|
|
43
43
|
static nullableWarned = false;
|
|
44
44
|
constructor(resolver, typeNameGenerator, discriminatorTargets, reservedNames) {
|
|
45
|
+
// NOTE: discriminatorTargets must be keyed by names produced by the SAME
|
|
46
|
+
// (rename-aware) typeNameGenerator that resolves $refs. Passing targets
|
|
47
|
+
// keyed by raw schema names means renamed subtypes silently lose their
|
|
48
|
+
// `& { prop: 'literal' }` intersection in the generated output.
|
|
45
49
|
this.resolver = resolver;
|
|
46
50
|
this.typeNameGenerator = typeNameGenerator ?? defaultTypeNameGenerator;
|
|
47
51
|
this.discriminatorTargets = discriminatorTargets ?? new Map();
|
|
@@ -237,7 +241,7 @@ export class SchemaMapper {
|
|
|
237
241
|
}
|
|
238
242
|
}
|
|
239
243
|
resolveDiscriminatorInfo(schema, refStr) {
|
|
240
|
-
const schemaName =
|
|
244
|
+
const schemaName = this.typeNameGenerator(refStr);
|
|
241
245
|
const target = this.discriminatorTargets.get(schemaName);
|
|
242
246
|
if (target)
|
|
243
247
|
return target;
|
package/dist/cli/app.d.ts
CHANGED
package/dist/cli/app.js
CHANGED
|
@@ -36,6 +36,13 @@ const command = buildCommand({
|
|
|
36
36
|
optional: true,
|
|
37
37
|
placeholder: 'module',
|
|
38
38
|
},
|
|
39
|
+
proxy: {
|
|
40
|
+
kind: 'parsed',
|
|
41
|
+
parse: String,
|
|
42
|
+
brief: 'HTTP(S) proxy URL for fetching specs (overrides HTTP_PROXY/HTTPS_PROXY env vars)',
|
|
43
|
+
optional: true,
|
|
44
|
+
placeholder: 'url',
|
|
45
|
+
},
|
|
39
46
|
},
|
|
40
47
|
positional: {
|
|
41
48
|
kind: 'tuple',
|
package/dist/cli/impl.js
CHANGED
|
@@ -2,11 +2,20 @@ import { generateFullOutput } from '../generator/client-generator.js';
|
|
|
2
2
|
import { load } from '../parser/spec-reader.js';
|
|
3
3
|
import { validateSpec } from '../parser/validators.js';
|
|
4
4
|
import { defaultRegistry } from '../parser/version/index.js';
|
|
5
|
+
import { assertValidProxyUrl } from '../utils/proxy.js';
|
|
5
6
|
import { UserError } from './errors.js';
|
|
6
7
|
export default async function (flags, spec) {
|
|
7
8
|
try {
|
|
9
|
+
if (flags.proxy) {
|
|
10
|
+
try {
|
|
11
|
+
assertValidProxyUrl(flags.proxy);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
throw new UserError(err.message);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
8
17
|
this.process.stdout.write(`Loading spec from ${spec}...\n`);
|
|
9
|
-
const doc = await load(spec);
|
|
18
|
+
const doc = await load(spec, { proxy: flags.proxy });
|
|
10
19
|
this.process.stdout.write(`Loaded OpenAPI ${doc.openapi} spec\n`);
|
|
11
20
|
const strategy = flags.specVersion
|
|
12
21
|
? defaultRegistry.get(flags.specVersion)
|
|
@@ -22,18 +22,20 @@ function substituteDiscriminatedType(tsType, schema, discriminatorInfo, renameMa
|
|
|
22
22
|
if (!refSchema || typeof refSchema !== 'object')
|
|
23
23
|
return tsType;
|
|
24
24
|
if (typeof refSchema.$ref === 'string') {
|
|
25
|
-
const
|
|
25
|
+
const rawName = refSchema.$ref.split('/').pop();
|
|
26
|
+
const schemaName = sanitizeTypeName(rawName);
|
|
26
27
|
if (schemaName && discriminatorInfo.has(schemaName)) {
|
|
27
|
-
const renamed = renameMap.get(
|
|
28
|
+
const renamed = renameMap.get(rawName) ?? schemaName;
|
|
28
29
|
return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
if (refSchema.items && typeof refSchema.items === 'object') {
|
|
32
33
|
const items = refSchema.items;
|
|
33
34
|
if (typeof items.$ref === 'string') {
|
|
34
|
-
const
|
|
35
|
+
const rawName = items.$ref.split('/').pop();
|
|
36
|
+
const schemaName = sanitizeTypeName(rawName);
|
|
35
37
|
if (schemaName && discriminatorInfo.has(schemaName)) {
|
|
36
|
-
const renamed = renameMap.get(
|
|
38
|
+
const renamed = renameMap.get(rawName) ?? schemaName;
|
|
37
39
|
return tsType.replace(new RegExp(`\\b${renamed}\\b`, 'g'), `${renamed}Variant`);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
@@ -245,13 +247,12 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
|
|
|
245
247
|
for (const [original, renamed] of renameMap) {
|
|
246
248
|
// TODO: Replace with structured logging solution
|
|
247
249
|
// oxlint-disable-next-line no-console
|
|
248
|
-
console.warn(`Warning: Schema "${original}" collides with a built-in type and was renamed to "${renamed}".`);
|
|
250
|
+
console.warn(`Warning: Schema "${original}" collides with a built-in type or another schema and was renamed to "${renamed}".`);
|
|
249
251
|
}
|
|
250
252
|
const renamingTypeGenerator = (refString) => {
|
|
251
253
|
const segments = refString.split('/');
|
|
252
254
|
const rawSegment = segments[segments.length - 1] || 'unknown';
|
|
253
|
-
|
|
254
|
-
return renameMap.get(lastSegment) ?? lastSegment;
|
|
255
|
+
return renameMap.get(rawSegment) ?? sanitizeTypeName(rawSegment);
|
|
255
256
|
};
|
|
256
257
|
const discriminatorInfo = new Map();
|
|
257
258
|
if (doc.components?.schemas) {
|
|
@@ -261,12 +262,13 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
|
|
|
261
262
|
const mapping = new Map();
|
|
262
263
|
if (resolved.discriminator.mapping) {
|
|
263
264
|
for (const [key, ref] of Object.entries(resolved.discriminator.mapping)) {
|
|
264
|
-
const
|
|
265
|
-
const renamedTarget = renameMap.get(
|
|
265
|
+
const rawTarget = ref.split('/').pop() || key;
|
|
266
|
+
const renamedTarget = renameMap.get(rawTarget) ?? sanitizeTypeName(rawTarget);
|
|
266
267
|
mapping.set(key, renamedTarget);
|
|
267
268
|
}
|
|
268
269
|
}
|
|
269
270
|
discriminatorInfo.set(sanitizeTypeName(name), {
|
|
271
|
+
rawName: name,
|
|
270
272
|
propertyName: resolved.discriminator.propertyName,
|
|
271
273
|
mapping,
|
|
272
274
|
});
|
|
@@ -285,7 +287,7 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
|
|
|
285
287
|
const allSchemaNames = new Set();
|
|
286
288
|
if (doc.components?.schemas) {
|
|
287
289
|
for (const name of Object.keys(doc.components.schemas)) {
|
|
288
|
-
allSchemaNames.add(sanitizeTypeName(name));
|
|
290
|
+
allSchemaNames.add(renameMap.get(name) ?? sanitizeTypeName(name));
|
|
289
291
|
}
|
|
290
292
|
}
|
|
291
293
|
const mapper = new SchemaMapper(resolver, renamingTypeGenerator, discriminatorTargets, allSchemaNames);
|
|
@@ -297,9 +299,8 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
|
|
|
297
299
|
const schemaEntries = [];
|
|
298
300
|
if (doc.components?.schemas) {
|
|
299
301
|
for (const [name, schema] of Object.entries(doc.components.schemas)) {
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
const result = mapper.mapSchema(schema, sanitizedName);
|
|
302
|
+
const renamedName = renameMap.get(name) ?? sanitizeTypeName(name);
|
|
303
|
+
const result = mapper.mapSchema(schema, renamedName);
|
|
303
304
|
const resolved = resolver.resolve(schema);
|
|
304
305
|
const definition = `export type ${renamedName} = ${result.tsType};`;
|
|
305
306
|
schemaEntries.push({
|
|
@@ -318,12 +319,12 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
|
|
|
318
319
|
}
|
|
319
320
|
lines.push(entry.definition);
|
|
320
321
|
}
|
|
321
|
-
for (const [
|
|
322
|
+
for (const [, info] of discriminatorInfo) {
|
|
322
323
|
const subtypeNames = Array.from(info.mapping.values());
|
|
323
324
|
if (subtypeNames.length === 0)
|
|
324
325
|
continue;
|
|
325
326
|
const unionType = subtypeNames.join(' | ');
|
|
326
|
-
const renamedBase = renameMap.get(
|
|
327
|
+
const renamedBase = renameMap.get(info.rawName) ?? sanitizeTypeName(info.rawName);
|
|
327
328
|
lines.push('');
|
|
328
329
|
lines.push(`export type ${renamedBase}Variant = ${unionType};`);
|
|
329
330
|
allSchemaNames.add(`${renamedBase}Variant`);
|
|
@@ -332,8 +333,16 @@ export function generateContracts(doc, resolver, runtimeImportPath = DEFAULT_RUN
|
|
|
332
333
|
const securitySchemes = doc.components?.securitySchemes;
|
|
333
334
|
if (securitySchemes && Object.keys(securitySchemes).length > 0) {
|
|
334
335
|
const securityTypeNames = [];
|
|
336
|
+
const usedSecurityTypeNames = new Set();
|
|
335
337
|
for (const [schemeName, scheme] of Object.entries(securitySchemes)) {
|
|
336
|
-
const
|
|
338
|
+
const baseTypeName = `${sanitizeTypeName(toPascalCase(schemeName))}Auth`;
|
|
339
|
+
let typeName = baseTypeName;
|
|
340
|
+
let n = 2;
|
|
341
|
+
while (usedSecurityTypeNames.has(typeName)) {
|
|
342
|
+
typeName = `${baseTypeName}${n}`;
|
|
343
|
+
n += 1;
|
|
344
|
+
}
|
|
345
|
+
usedSecurityTypeNames.add(typeName);
|
|
337
346
|
const tsType = securitySchemeToTsType(scheme);
|
|
338
347
|
const schemeJsDoc = buildDescriptionJsDoc(scheme.description);
|
|
339
348
|
if (schemeJsDoc !== '') {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type ApiClient } from './generator/client-generator.js';
|
|
2
2
|
import type { GeneratorConfig } from './types/client.js';
|
|
3
|
+
import { type LoadOptions } from './utils/proxy.js';
|
|
3
4
|
export declare function generateClient(config: GeneratorConfig): Promise<void>;
|
|
4
5
|
export { ApiError, UnspecifiedApiError, DefaultApiError, RequesterFailError, StreamResponse, ErrorResponse, decorateWithErrors, isDefinedError, } from './runtime/index.js';
|
|
5
6
|
export type { Requester } from './runtime/index.js';
|
|
6
|
-
export type { GeneratorConfig, ApiClient };
|
|
7
|
+
export type { GeneratorConfig, ApiClient, LoadOptions };
|
|
7
8
|
export type { GenerationOptions } from './generator/client-generator.js';
|
|
8
9
|
export { load as loadSpec } from './parser/spec-reader.js';
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { generateFullOutput } from './generator/client-generator.js';
|
|
2
2
|
import { load } from './parser/spec-reader.js';
|
|
3
|
+
import { assertValidProxyUrl } from './utils/proxy.js';
|
|
3
4
|
export async function generateClient(config) {
|
|
4
|
-
|
|
5
|
+
if (config.proxy)
|
|
6
|
+
assertValidProxyUrl(config.proxy);
|
|
7
|
+
const doc = await load(config.input, { proxy: config.proxy });
|
|
5
8
|
await generateFullOutput(doc, config);
|
|
6
9
|
}
|
|
7
10
|
export { ApiError, UnspecifiedApiError, DefaultApiError, RequesterFailError, StreamResponse, ErrorResponse, decorateWithErrors, isDefinedError, } from './runtime/index.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OpenAPIDocument } from '../types/openapi.js';
|
|
2
|
+
import { type LoadOptions } from '../utils/proxy.js';
|
|
2
3
|
export declare function loadFromFile(filePath: string): Promise<OpenAPIDocument>;
|
|
3
|
-
export declare function loadFromUrl(url: string): Promise<OpenAPIDocument>;
|
|
4
|
-
export declare function load(source: string): Promise<OpenAPIDocument>;
|
|
4
|
+
export declare function loadFromUrl(url: string, opts?: LoadOptions): Promise<OpenAPIDocument>;
|
|
5
|
+
export declare function load(source: string, opts?: LoadOptions): Promise<OpenAPIDocument>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { fetchSpec } from '../utils/proxy.js';
|
|
4
5
|
import { isUrl } from '../utils/url.js';
|
|
5
6
|
const VALID_EXTENSIONS = ['.json', '.yaml', '.yml'];
|
|
6
7
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
@@ -74,10 +75,10 @@ export async function loadFromFile(filePath) {
|
|
|
74
75
|
assertSupportedVersion(doc);
|
|
75
76
|
return doc;
|
|
76
77
|
}
|
|
77
|
-
export async function loadFromUrl(url) {
|
|
78
|
+
export async function loadFromUrl(url, opts) {
|
|
78
79
|
let response;
|
|
79
80
|
try {
|
|
80
|
-
response = await
|
|
81
|
+
response = await fetchSpec(url, opts);
|
|
81
82
|
}
|
|
82
83
|
catch (err) {
|
|
83
84
|
throw new Error(`Failed to fetch spec from URL: ${err.message}`, { cause: err });
|
|
@@ -108,9 +109,9 @@ export async function loadFromUrl(url) {
|
|
|
108
109
|
assertSupportedVersion(doc);
|
|
109
110
|
return doc;
|
|
110
111
|
}
|
|
111
|
-
export async function load(source) {
|
|
112
|
+
export async function load(source, opts) {
|
|
112
113
|
if (isUrl(source)) {
|
|
113
|
-
return loadFromUrl(source);
|
|
114
|
+
return loadFromUrl(source, opts);
|
|
114
115
|
}
|
|
115
116
|
return loadFromFile(source);
|
|
116
117
|
}
|
package/dist/types/client.d.ts
CHANGED
|
@@ -9,15 +9,32 @@ export declare const RESERVED_TYPE_NAMES: ReadonlySet<string>;
|
|
|
9
9
|
/** Default module specifier generated code imports shared runtime classes from. */
|
|
10
10
|
export declare const DEFAULT_RUNTIME_IMPORT_PATH = "genoc/runtime";
|
|
11
11
|
/**
|
|
12
|
-
* Sanitize a schema type name
|
|
13
|
-
* PascalCase. Names without dots pass through unchanged.
|
|
12
|
+
* Sanitize a schema type name into a valid TypeScript identifier.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
14
|
+
* Any run of non-identifier characters (dots, brackets, backticks, hyphens,
|
|
15
|
+
* spaces, slashes, ...) splits the name into segments that are re-joined in
|
|
16
|
+
* PascalCase:
|
|
17
|
+
*
|
|
18
|
+
* "Api.Error" → "ApiError"
|
|
19
|
+
* "User[Dto]" → "UserDto"
|
|
20
|
+
* "my-schema" → "MySchema"
|
|
21
|
+
* "payment id" → "PaymentId"
|
|
22
|
+
*
|
|
23
|
+
* Names that are already valid identifiers pass through unchanged
|
|
24
|
+
* ("User", "my_schema", "mySchema", "$Foo", "Данные"). Results that would
|
|
25
|
+
* start with a digit ("2FA") get an "_" prefix, as do exact reserved words
|
|
26
|
+
* ("class" → "_class"). Names with no usable characters collapse to "_".
|
|
16
27
|
*/
|
|
17
28
|
export declare function sanitizeTypeName(name: string): string;
|
|
18
29
|
/**
|
|
19
|
-
* Build a mapping of
|
|
20
|
-
*
|
|
30
|
+
* Build a mapping of raw schema names → renamed names for any whose
|
|
31
|
+
* sanitized name collides with a reserved type name or with another
|
|
32
|
+
* schema's sanitized name (e.g. "User-Dto" and "User[Dto]" both → "UserDto").
|
|
33
|
+
*
|
|
34
|
+
* The map is keyed by the raw schema name (component keys are unique by
|
|
35
|
+
* construction), so callers resolve raw $ref segments / schema keys first
|
|
36
|
+
* and fall back to sanitizeTypeName for the common no-collision case.
|
|
37
|
+
* Numbered suffixes are used when the first renamed candidate also collides.
|
|
21
38
|
*/
|
|
22
39
|
export declare function buildSchemaRenameMap(schemaNames: Iterable<string>, reserved: ReadonlySet<string>, suffix?: string): Map<string, string>;
|
|
23
40
|
/**
|
|
@@ -28,6 +45,10 @@ export declare function toPascalCase(str: string): string;
|
|
|
28
45
|
/**
|
|
29
46
|
* Build a PascalCase type-name prefix from an operation's method + path.
|
|
30
47
|
* get + /api/v1/products → "GetApiV1Products"
|
|
48
|
+
*
|
|
49
|
+
* Returns the deduped prefix assigned by analyzePaths when available
|
|
50
|
+
* (distinct routes folding to the same identifier get numbered suffixes),
|
|
51
|
+
* otherwise computes it from the operation directly.
|
|
31
52
|
*/
|
|
32
53
|
export declare function getOperationTypePrefix(op: AnalyzedOperation): string;
|
|
33
54
|
/**
|
|
@@ -1,3 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript/JavaScript reserved words, matched case-sensitively: a type
|
|
3
|
+
* named "Null" or "Class" is legal, only the exact lowercase keywords are.
|
|
4
|
+
*/
|
|
5
|
+
const RESERVED_WORDS = new Set([
|
|
6
|
+
'abstract',
|
|
7
|
+
'any',
|
|
8
|
+
'as',
|
|
9
|
+
'asserts',
|
|
10
|
+
'assert',
|
|
11
|
+
'async',
|
|
12
|
+
'await',
|
|
13
|
+
'boolean',
|
|
14
|
+
'break',
|
|
15
|
+
'case',
|
|
16
|
+
'catch',
|
|
17
|
+
'class',
|
|
18
|
+
'continue',
|
|
19
|
+
'const',
|
|
20
|
+
'constructor',
|
|
21
|
+
'debugger',
|
|
22
|
+
'declare',
|
|
23
|
+
'default',
|
|
24
|
+
'delete',
|
|
25
|
+
'do',
|
|
26
|
+
'else',
|
|
27
|
+
'enum',
|
|
28
|
+
'export',
|
|
29
|
+
'extends',
|
|
30
|
+
'false',
|
|
31
|
+
'finally',
|
|
32
|
+
'for',
|
|
33
|
+
'from',
|
|
34
|
+
'function',
|
|
35
|
+
'get',
|
|
36
|
+
'if',
|
|
37
|
+
'implements',
|
|
38
|
+
'import',
|
|
39
|
+
'in',
|
|
40
|
+
'infer',
|
|
41
|
+
'instanceof',
|
|
42
|
+
'interface',
|
|
43
|
+
'is',
|
|
44
|
+
'keyof',
|
|
45
|
+
'let',
|
|
46
|
+
'module',
|
|
47
|
+
'namespace',
|
|
48
|
+
'never',
|
|
49
|
+
'new',
|
|
50
|
+
'null',
|
|
51
|
+
'number',
|
|
52
|
+
'object',
|
|
53
|
+
'package',
|
|
54
|
+
'private',
|
|
55
|
+
'protected',
|
|
56
|
+
'public',
|
|
57
|
+
'readonly',
|
|
58
|
+
'require',
|
|
59
|
+
'return',
|
|
60
|
+
'satisfies',
|
|
61
|
+
'set',
|
|
62
|
+
'static',
|
|
63
|
+
'string',
|
|
64
|
+
'super',
|
|
65
|
+
'switch',
|
|
66
|
+
'symbol',
|
|
67
|
+
'this',
|
|
68
|
+
'throw',
|
|
69
|
+
'true',
|
|
70
|
+
'try',
|
|
71
|
+
'type',
|
|
72
|
+
'typeof',
|
|
73
|
+
'undefined',
|
|
74
|
+
'unique',
|
|
75
|
+
'unknown',
|
|
76
|
+
'var',
|
|
77
|
+
'void',
|
|
78
|
+
'while',
|
|
79
|
+
'with',
|
|
80
|
+
'yield',
|
|
81
|
+
]);
|
|
82
|
+
function isReservedTypeName(name) {
|
|
83
|
+
return RESERVED_WORDS.has(name);
|
|
84
|
+
}
|
|
1
85
|
/**
|
|
2
86
|
* Reserved type names used by the generated output's built-in classes,
|
|
3
87
|
* functions, and types. User-defined schema names that collide with these
|
|
@@ -22,30 +106,72 @@ export const RESERVED_TYPE_NAMES = new Set([
|
|
|
22
106
|
/** Default module specifier generated code imports shared runtime classes from. */
|
|
23
107
|
export const DEFAULT_RUNTIME_IMPORT_PATH = 'genoc/runtime';
|
|
24
108
|
/**
|
|
25
|
-
* Sanitize a schema type name
|
|
26
|
-
*
|
|
109
|
+
* Sanitize a schema type name into a valid TypeScript identifier.
|
|
110
|
+
*
|
|
111
|
+
* Any run of non-identifier characters (dots, brackets, backticks, hyphens,
|
|
112
|
+
* spaces, slashes, ...) splits the name into segments that are re-joined in
|
|
113
|
+
* PascalCase:
|
|
27
114
|
*
|
|
28
|
-
*
|
|
115
|
+
* "Api.Error" → "ApiError"
|
|
116
|
+
* "User[Dto]" → "UserDto"
|
|
117
|
+
* "my-schema" → "MySchema"
|
|
118
|
+
* "payment id" → "PaymentId"
|
|
119
|
+
*
|
|
120
|
+
* Names that are already valid identifiers pass through unchanged
|
|
121
|
+
* ("User", "my_schema", "mySchema", "$Foo", "Данные"). Results that would
|
|
122
|
+
* start with a digit ("2FA") get an "_" prefix, as do exact reserved words
|
|
123
|
+
* ("class" → "_class"). Names with no usable characters collapse to "_".
|
|
29
124
|
*/
|
|
30
125
|
export function sanitizeTypeName(name) {
|
|
31
|
-
if (!name
|
|
126
|
+
if (!name)
|
|
127
|
+
return '_';
|
|
128
|
+
// Exact reserved words keep their casing and get an "_" prefix
|
|
129
|
+
// ("class" → "_class"), while "Null" / "Class" are legal and pass through.
|
|
130
|
+
if (isReservedTypeName(name))
|
|
131
|
+
return `_${name}`;
|
|
132
|
+
const isValidIdentifier = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u.test(name);
|
|
133
|
+
if (isValidIdentifier)
|
|
32
134
|
return name;
|
|
33
|
-
|
|
34
|
-
|
|
135
|
+
const segments = name.match(/[\p{ID_Continue}$]+/gu) ?? [];
|
|
136
|
+
const result = segments
|
|
35
137
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
36
138
|
.join('');
|
|
139
|
+
// Covers empty results, ASCII digits, and non-ASCII digits (Nd but not
|
|
140
|
+
// ID_Start, e.g. Arabic-Indic) alike: anything not starting with a valid
|
|
141
|
+
// identifier start character gets the "_" prefix.
|
|
142
|
+
if (!/^[\p{ID_Start}$_]/u.test(result))
|
|
143
|
+
return `_${result}`;
|
|
144
|
+
if (isReservedTypeName(result))
|
|
145
|
+
return `_${result}`;
|
|
146
|
+
return result;
|
|
37
147
|
}
|
|
38
148
|
/**
|
|
39
|
-
* Build a mapping of
|
|
40
|
-
*
|
|
149
|
+
* Build a mapping of raw schema names → renamed names for any whose
|
|
150
|
+
* sanitized name collides with a reserved type name or with another
|
|
151
|
+
* schema's sanitized name (e.g. "User-Dto" and "User[Dto]" both → "UserDto").
|
|
152
|
+
*
|
|
153
|
+
* The map is keyed by the raw schema name (component keys are unique by
|
|
154
|
+
* construction), so callers resolve raw $ref segments / schema keys first
|
|
155
|
+
* and fall back to sanitizeTypeName for the common no-collision case.
|
|
156
|
+
* Numbered suffixes are used when the first renamed candidate also collides.
|
|
41
157
|
*/
|
|
42
158
|
export function buildSchemaRenameMap(schemaNames, reserved, suffix = 'Model') {
|
|
43
159
|
const renameMap = new Map();
|
|
160
|
+
const used = new Set(reserved);
|
|
44
161
|
for (const name of schemaNames) {
|
|
45
162
|
const sanitized = sanitizeTypeName(name);
|
|
46
|
-
if (
|
|
47
|
-
|
|
163
|
+
if (!used.has(sanitized)) {
|
|
164
|
+
used.add(sanitized);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
let candidate = `${sanitized}${suffix}`;
|
|
168
|
+
let counter = 2;
|
|
169
|
+
while (used.has(candidate)) {
|
|
170
|
+
candidate = `${sanitized}${suffix}${counter}`;
|
|
171
|
+
counter += 1;
|
|
48
172
|
}
|
|
173
|
+
used.add(candidate);
|
|
174
|
+
renameMap.set(name, candidate);
|
|
49
175
|
}
|
|
50
176
|
return renameMap;
|
|
51
177
|
}
|
|
@@ -64,15 +190,24 @@ export function toPascalCase(str) {
|
|
|
64
190
|
/**
|
|
65
191
|
* Build a PascalCase type-name prefix from an operation's method + path.
|
|
66
192
|
* get + /api/v1/products → "GetApiV1Products"
|
|
193
|
+
*
|
|
194
|
+
* Returns the deduped prefix assigned by analyzePaths when available
|
|
195
|
+
* (distinct routes folding to the same identifier get numbered suffixes),
|
|
196
|
+
* otherwise computes it from the operation directly.
|
|
67
197
|
*/
|
|
68
198
|
export function getOperationTypePrefix(op) {
|
|
199
|
+
if (op.typePrefix)
|
|
200
|
+
return op.typePrefix;
|
|
201
|
+
return computeOperationTypePrefix(op);
|
|
202
|
+
}
|
|
203
|
+
function computeOperationTypePrefix(op) {
|
|
69
204
|
const methodPascal = op.method.charAt(0).toUpperCase() + op.method.slice(1).toLowerCase();
|
|
70
205
|
const segments = op.path
|
|
71
206
|
.split('/')
|
|
72
207
|
.filter((s) => s.length > 0)
|
|
73
208
|
.map((s) => {
|
|
74
209
|
const cleaned = s.replace(/[{}]/g, '');
|
|
75
|
-
return toPascalCase(cleaned);
|
|
210
|
+
return sanitizeTypeName(toPascalCase(cleaned));
|
|
76
211
|
});
|
|
77
212
|
return methodPascal + segments.join('');
|
|
78
213
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:']);
|
|
2
|
+
/** Security: never echo credentials — user:pass@ becomes ***@ in any echoed value. */
|
|
3
|
+
function redactCredentials(proxyUrl) {
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(proxyUrl);
|
|
6
|
+
if (url.username || url.password) {
|
|
7
|
+
url.username = '***';
|
|
8
|
+
url.password = '';
|
|
9
|
+
}
|
|
10
|
+
return url.toString();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return proxyUrl;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
17
|
+
/**
|
|
18
|
+
* undici's fetch() nests the meaningful error up to 3 levels deep via `cause`
|
|
19
|
+
* (e.g. "fetch failed" → "Request was cancelled." → "Proxy response (502) !== 200...").
|
|
20
|
+
* Flatten the chain so the deepest message (proxy status, ECONNREFUSED, ...) is user-visible.
|
|
21
|
+
*/
|
|
22
|
+
function describeFetchError(err) {
|
|
23
|
+
const messages = [];
|
|
24
|
+
let current = err;
|
|
25
|
+
for (let depth = 0; depth <= MAX_CAUSE_DEPTH && current instanceof Error; depth++) {
|
|
26
|
+
if (!messages.includes(current.message)) {
|
|
27
|
+
messages.push(current.message);
|
|
28
|
+
}
|
|
29
|
+
current = current.cause;
|
|
30
|
+
}
|
|
31
|
+
return messages.join(': ');
|
|
32
|
+
}
|
|
33
|
+
export function assertValidProxyUrl(url) {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = new URL(url);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`Invalid proxy URL "${redactCredentials(url)}": only http:// and https:// proxies are supported`);
|
|
40
|
+
}
|
|
41
|
+
if (!SUPPORTED_PROXY_PROTOCOLS.has(parsed.protocol)) {
|
|
42
|
+
throw new Error(`Invalid proxy URL "${redactCredentials(url)}": only http:// and https:// proxies are supported (got scheme "${parsed.protocol.replace(/:$/, '')}:")`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function hasProxyEnv() {
|
|
46
|
+
return Boolean(process.env.HTTP_PROXY ||
|
|
47
|
+
process.env.http_proxy ||
|
|
48
|
+
process.env.HTTPS_PROXY ||
|
|
49
|
+
process.env.https_proxy);
|
|
50
|
+
}
|
|
51
|
+
export async function fetchSpec(url, opts) {
|
|
52
|
+
if (opts?.proxy) {
|
|
53
|
+
assertValidProxyUrl(opts.proxy);
|
|
54
|
+
const redacted = redactCredentials(opts.proxy);
|
|
55
|
+
try {
|
|
56
|
+
const { fetch: undiciFetch, ProxyAgent } = await import('undici');
|
|
57
|
+
return (await undiciFetch(url, {
|
|
58
|
+
dispatcher: new ProxyAgent(opts.proxy),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
throw new Error(`Failed to fetch spec from URL via proxy ${redacted}: ${describeFetchError(err)}`, {
|
|
63
|
+
cause: err,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (hasProxyEnv()) {
|
|
68
|
+
try {
|
|
69
|
+
const { fetch: undiciFetch, EnvHttpProxyAgent } = await import('undici');
|
|
70
|
+
return (await undiciFetch(url, {
|
|
71
|
+
dispatcher: new EnvHttpProxyAgent(),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
throw new Error(`Failed to fetch spec from URL via proxy env: ${describeFetchError(err)}`, {
|
|
76
|
+
cause: err,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return fetch(url);
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genoc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Generate typed HTTP clients from OpenAPI 3.0 / 3.1 specifications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"client",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@stricli/core": "^1.2.6",
|
|
51
|
+
"undici": "^6.13.0",
|
|
51
52
|
"yaml": "^2.8.4"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { AnalyzedOperation } from '../analyzer/path-analyzer.js';
|
|
2
|
-
/**
|
|
3
|
-
* Generate error types for a set of analyzed operations.
|
|
4
|
-
*
|
|
5
|
-
* Produces:
|
|
6
|
-
* 1. `ApiError<TStatus, TData>` class
|
|
7
|
-
* 2. `UnspecifiedApiError` class (extends ApiError, for status codes not in spec)
|
|
8
|
-
* 3. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
|
|
9
|
-
* 4. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
|
|
10
|
-
* 5. `DefaultErrorBody` type for `default` responses
|
|
11
|
-
* 6. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
|
|
12
|
-
*/
|
|
13
|
-
export declare function generateErrorTypes(operations: AnalyzedOperation[]): string;
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generate error types for a set of analyzed operations.
|
|
3
|
-
*
|
|
4
|
-
* Produces:
|
|
5
|
-
* 1. `ApiError<TStatus, TData>` class
|
|
6
|
-
* 2. `UnspecifiedApiError` class (extends ApiError, for status codes not in spec)
|
|
7
|
-
* 3. Per-operation error union types (e.g. `GetApiV1ProductsErrors`)
|
|
8
|
-
* 4. Per-operation per-status error type aliases (e.g. `GetApiV1ProductsError400`)
|
|
9
|
-
* 5. `DefaultErrorBody` type for `default` responses
|
|
10
|
-
* 6. Catch-all `UnspecifiedApiError` in error unions for unexpected status codes
|
|
11
|
-
*/
|
|
12
|
-
export function generateErrorTypes(operations) {
|
|
13
|
-
const lines = [];
|
|
14
|
-
lines.push(`export class ApiError<TStatus extends number, TData> extends Error {
|
|
15
|
-
constructor(
|
|
16
|
-
public readonly status: TStatus,
|
|
17
|
-
public readonly data: TData,
|
|
18
|
-
message: string,
|
|
19
|
-
) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.name = "ApiError";
|
|
22
|
-
}
|
|
23
|
-
}`);
|
|
24
|
-
lines.push('');
|
|
25
|
-
lines.push(`export class UnspecifiedApiError extends ApiError<number, unknown> {
|
|
26
|
-
constructor(
|
|
27
|
-
status: number,
|
|
28
|
-
data: unknown,
|
|
29
|
-
message: string,
|
|
30
|
-
) {
|
|
31
|
-
super(status, data, message);
|
|
32
|
-
this.name = "UnspecifiedApiError";
|
|
33
|
-
}
|
|
34
|
-
}`);
|
|
35
|
-
lines.push('');
|
|
36
|
-
let needsDefaultErrorBody = false;
|
|
37
|
-
for (const op of operations) {
|
|
38
|
-
const errorResponses = op.responses.filter((r) => !r.isSuccess && r.statusCode !== 'default');
|
|
39
|
-
const defaultResponse = op.responses.find((r) => !r.isSuccess && r.statusCode === 'default');
|
|
40
|
-
if (errorResponses.length === 0 && !defaultResponse) {
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
const methodName = op.methodName;
|
|
44
|
-
const errorTypeName = (status) => `${methodName}Error${status}`;
|
|
45
|
-
lines.push('');
|
|
46
|
-
for (const err of errorResponses) {
|
|
47
|
-
const tsType = err.tsType || 'unknown';
|
|
48
|
-
lines.push(`export type ${errorTypeName(err.statusCode)} = ${tsType};`);
|
|
49
|
-
}
|
|
50
|
-
const unionParts = [];
|
|
51
|
-
for (const err of errorResponses) {
|
|
52
|
-
const status = Number(err.statusCode);
|
|
53
|
-
unionParts.push(`ApiError<${status}, ${errorTypeName(err.statusCode)}>`);
|
|
54
|
-
}
|
|
55
|
-
if (defaultResponse) {
|
|
56
|
-
needsDefaultErrorBody = true;
|
|
57
|
-
unionParts.push(`ApiError<number, DefaultErrorBody>`);
|
|
58
|
-
}
|
|
59
|
-
unionParts.push(`UnspecifiedApiError`);
|
|
60
|
-
lines.push(`export type ${methodName}Errors = ${unionParts.join('\n | ')};`);
|
|
61
|
-
}
|
|
62
|
-
if (needsDefaultErrorBody) {
|
|
63
|
-
lines.push('');
|
|
64
|
-
lines.push('export type DefaultErrorBody = unknown;');
|
|
65
|
-
}
|
|
66
|
-
return lines.join('\n');
|
|
67
|
-
}
|