api 4.4.0 → 5.0.0-beta.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.
Files changed (70) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +18 -5
  3. package/bin/api +2 -0
  4. package/dist/bin.d.ts +1 -0
  5. package/dist/bin.js +91 -0
  6. package/dist/cache.d.ts +30 -0
  7. package/dist/cache.js +217 -0
  8. package/dist/cli/codegen/index.d.ts +4 -0
  9. package/dist/cli/codegen/index.js +23 -0
  10. package/dist/cli/codegen/language.d.ts +27 -0
  11. package/dist/cli/codegen/language.js +19 -0
  12. package/dist/cli/codegen/languages/typescript.d.ts +99 -0
  13. package/dist/cli/codegen/languages/typescript.js +762 -0
  14. package/dist/cli/commands/index.d.ts +4 -0
  15. package/dist/cli/commands/index.js +9 -0
  16. package/dist/cli/commands/install.d.ts +3 -0
  17. package/dist/cli/commands/install.js +230 -0
  18. package/dist/cli/lib/prompt.d.ts +9 -0
  19. package/dist/cli/lib/prompt.js +81 -0
  20. package/dist/cli/logger.d.ts +1 -0
  21. package/dist/cli/logger.js +16 -0
  22. package/dist/cli/storage.d.ts +105 -0
  23. package/dist/cli/storage.js +264 -0
  24. package/dist/core/getJSONSchemaDefaults.d.ts +15 -0
  25. package/dist/core/getJSONSchemaDefaults.js +62 -0
  26. package/dist/core/index.d.ts +32 -0
  27. package/dist/core/index.js +143 -0
  28. package/dist/core/parseResponse.d.ts +1 -0
  29. package/dist/core/parseResponse.js +65 -0
  30. package/dist/core/prepareAuth.d.ts +5 -0
  31. package/dist/core/prepareAuth.js +55 -0
  32. package/dist/core/prepareParams.d.ts +24 -0
  33. package/dist/core/prepareParams.js +351 -0
  34. package/dist/core/prepareServer.d.ts +13 -0
  35. package/dist/core/prepareServer.js +50 -0
  36. package/dist/fetcher.d.ts +53 -0
  37. package/dist/fetcher.js +149 -0
  38. package/dist/index.d.ts +6 -0
  39. package/dist/index.js +276 -0
  40. package/dist/packageInfo.d.ts +2 -0
  41. package/dist/packageInfo.js +6 -0
  42. package/package.json +65 -25
  43. package/src/.sink.d.ts +1 -0
  44. package/src/bin.ts +20 -0
  45. package/src/cache.ts +212 -0
  46. package/src/cli/codegen/index.ts +31 -0
  47. package/src/cli/codegen/language.ts +47 -0
  48. package/src/cli/codegen/languages/typescript.ts +798 -0
  49. package/src/cli/commands/index.ts +5 -0
  50. package/src/cli/commands/install.ts +196 -0
  51. package/src/cli/lib/prompt.ts +29 -0
  52. package/src/cli/logger.ts +10 -0
  53. package/src/cli/storage.ts +297 -0
  54. package/src/core/getJSONSchemaDefaults.ts +74 -0
  55. package/src/core/index.ts +108 -0
  56. package/src/{lib/parseResponse.js → core/parseResponse.ts} +5 -7
  57. package/src/core/prepareAuth.ts +85 -0
  58. package/src/core/prepareParams.ts +338 -0
  59. package/src/{lib/prepareServer.js → core/prepareServer.ts} +13 -12
  60. package/src/fetcher.ts +126 -0
  61. package/src/index.ts +212 -0
  62. package/src/packageInfo.ts +3 -0
  63. package/src/typings.d.ts +3 -0
  64. package/tsconfig.json +24 -0
  65. package/src/cache.js +0 -209
  66. package/src/index.js +0 -175
  67. package/src/lib/getSchema.js +0 -34
  68. package/src/lib/index.js +0 -11
  69. package/src/lib/prepareAuth.js +0 -69
  70. package/src/lib/prepareParams.js +0 -198
@@ -0,0 +1,798 @@
1
+ import type Oas from 'oas';
2
+ import type { Operation } from 'oas';
3
+ import type { HttpMethods, JSONSchema, SchemaObject } from 'oas/@types/rmoas.types';
4
+ import type {
5
+ ClassDeclaration,
6
+ JSDocStructure,
7
+ MethodDeclaration,
8
+ OptionalKind,
9
+ ParameterDeclarationStructure,
10
+ TypeParameterDeclarationStructure,
11
+ } from 'ts-morph';
12
+ import type { Options as JSONSchemaToTypescriptOptions } from 'json-schema-to-typescript';
13
+ import type Storage from '../../storage';
14
+ import type { InstallerOptions } from '../language';
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import CodeGeneratorLanguage from '../language';
19
+ import logger from '../../logger';
20
+ import objectHash from 'object-hash';
21
+ import { IndentationText, Project, QuoteKind, ScriptTarget } from 'ts-morph';
22
+ import { compile } from 'json-schema-to-typescript';
23
+ import { format as prettier } from 'json-schema-to-typescript/dist/src/formatter';
24
+ import execa from 'execa';
25
+
26
+ type OperationTypeHousing = {
27
+ types: {
28
+ params?: false | Record<'body' | 'formData' | 'metadata', string>;
29
+ responses?: Record<string, string>;
30
+ };
31
+ operation: Operation;
32
+ };
33
+
34
+ // https://www.30secondsofcode.org/js/s/word-wrap
35
+ function wordWrap(str: string, max = 88) {
36
+ return str.replace(new RegExp(`(?![^\\n]{1,${max}}$)([^\\n]{1,${max}})\\s`, 'g'), '$1\n');
37
+ }
38
+
39
+ export default class TSGenerator extends CodeGeneratorLanguage {
40
+ project: Project;
41
+
42
+ outputJS: boolean;
43
+
44
+ compilerTarget: 'cjs' | 'esm';
45
+
46
+ types: Map<string, string>;
47
+
48
+ files: Record<string, string>;
49
+
50
+ methodGenerics: Map<string, MethodDeclaration>;
51
+
52
+ sdk: ClassDeclaration;
53
+
54
+ schemas: Map<
55
+ string,
56
+ {
57
+ schema: SchemaObject;
58
+ name: string;
59
+ tsType?: string;
60
+ }
61
+ >;
62
+
63
+ constructor(
64
+ spec: Oas,
65
+ specPath: string,
66
+ identifier: string,
67
+ opts: {
68
+ outputJS?: boolean;
69
+ compilerTarget?: 'cjs' | 'esm';
70
+ } = {}
71
+ ) {
72
+ const options: { outputJS: boolean; compilerTarget: 'cjs' | 'esm' } = {
73
+ outputJS: false,
74
+ compilerTarget: 'cjs',
75
+ ...opts,
76
+ };
77
+
78
+ if (!options.outputJS) {
79
+ // TypeScript compilation will always target towards ESM-like imports and exports.
80
+ options.compilerTarget = 'esm';
81
+ }
82
+
83
+ super(spec, specPath, identifier);
84
+
85
+ this.requiredPackages = {
86
+ 'api@beta': {
87
+ reason: "Required for the `api/dist/core` library that the codegen'd SDK uses for making requests.",
88
+ url: 'https://npm.im/api',
89
+ },
90
+ oas: {
91
+ reason: 'Used within `api/dist/core` and is also loaded for TypeScript types.',
92
+ url: 'https://npm.im/oas',
93
+ },
94
+ };
95
+
96
+ this.project = new Project({
97
+ manipulationSettings: {
98
+ indentationText: IndentationText.TwoSpaces,
99
+ quoteKind: QuoteKind.Single,
100
+ },
101
+ compilerOptions: {
102
+ declaration: true,
103
+ resolveJsonModule: true,
104
+ target: options.compilerTarget === 'cjs' ? ScriptTarget.ES5 : ScriptTarget.ES2020,
105
+ outDir: 'dist',
106
+
107
+ // If we're compiling to a CJS target then we need to include this compiler option
108
+ // otherwise TS will attempt to load our `openapi.json` import with a `.default` property
109
+ // which doesn't exist. `esModuleInterop` wraps imports in a small `__importDefault`
110
+ // function that does some determination to see if the module has a default export or not.
111
+ //
112
+ // Basically without this option CJS code will fail.
113
+ ...(options.compilerTarget === 'cjs' ? { esModuleInterop: true } : {}),
114
+ },
115
+ });
116
+
117
+ this.compilerTarget = options.compilerTarget;
118
+ this.outputJS = options.outputJS;
119
+
120
+ this.types = new Map();
121
+ this.methodGenerics = new Map();
122
+ this.schemas = new Map();
123
+ }
124
+
125
+ static formatter(content: string) {
126
+ return prettier(content, {
127
+ format: true,
128
+ style: {
129
+ printWidth: 120,
130
+ singleQuote: true,
131
+ },
132
+ } as JSONSchemaToTypescriptOptions);
133
+ }
134
+
135
+ async installer(storage: Storage, opts: InstallerOptions = {}): Promise<void> {
136
+ const installDir = storage.getIdentifierStorageDir();
137
+
138
+ const pkg = {
139
+ name: `@api/${storage.identifier}`,
140
+ main: `./index.${this.outputJS ? 'js' : 'ts'}`,
141
+ types: './index.d.ts', // Types are always present regardless if you're getting compiled JS.
142
+ };
143
+
144
+ fs.writeFileSync(path.join(installDir, 'package.json'), JSON.stringify(pkg, null, 2));
145
+
146
+ const npmInstall = ['install', '--save', opts.dryRun ? '--dry-run' : ''].filter(Boolean);
147
+
148
+ // This will install packages required for the SDK within its installed directory in `.apis/`.
149
+ await execa('npm', [...npmInstall, ...Object.keys(this.requiredPackages)].filter(Boolean), {
150
+ cwd: installDir,
151
+ }).then(res => {
152
+ if (opts.dryRun) {
153
+ (opts.logger ? opts.logger : logger)(res.command);
154
+ (opts.logger ? opts.logger : logger)(res.stdout);
155
+ }
156
+ });
157
+
158
+ // This will install the installed SDK as a dependency within the current working directory,
159
+ // adding `@api/<sdk identifier>` as a dependency there so you can load it with
160
+ // `require('@api/<sdk identifier>)`.
161
+ return execa('npm', [...npmInstall, storage.getIdentifierStorageDir()].filter(Boolean)).then(res => {
162
+ if (opts.dryRun) {
163
+ (opts.logger ? opts.logger : logger)(res.command);
164
+ (opts.logger ? opts.logger : logger)(res.stdout);
165
+ }
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Compile the current OpenAPI definition into a TypeScript library.
171
+ *
172
+ */
173
+ async generator() {
174
+ const { operations, methods } = await this.loadOperationsAndMethods();
175
+
176
+ const sdkSource = this.project.createSourceFile('index.ts', '');
177
+
178
+ sdkSource.addImportDeclarations([
179
+ { defaultImport: 'Oas', moduleSpecifier: 'oas' },
180
+ { defaultImport: 'APICore', moduleSpecifier: 'api/dist/core' },
181
+ { defaultImport: 'definition', moduleSpecifier: this.specPath },
182
+ ]);
183
+
184
+ // @todo add TOS, License, info.* to a docblock at the top of the SDK.
185
+ this.sdk = sdkSource.addClass({
186
+ name: 'SDK',
187
+ });
188
+
189
+ // There's an annoying quirk with `ts-morph` where if we set the SDK class to be the default
190
+ // export with `isDefaultExport` then when we compile it to an ES5 target for CJS environments
191
+ // it'll be exported as `export.default = SDK`, which when you try to load it you'll need to
192
+ // run `require('@api/sdk').default`.
193
+ //
194
+ // Instead here by plainly creating the SDK class in the source file and then setting this
195
+ // export assignment it'll export the SDK class as `module.exports = SDK` so people can cleanly
196
+ // load the SDK with `require('@api/sdk)`.
197
+ //
198
+ // A whole lot of debugging went into here to let people not have to worry about `.default`
199
+ // messes. I hope it's worth it!
200
+ if (this.compilerTarget === 'cjs') {
201
+ sdkSource.addExportAssignment({
202
+ expression: 'SDK',
203
+ });
204
+ } else {
205
+ this.sdk.setIsDefaultExport(true);
206
+ }
207
+
208
+ this.sdk.addProperties([
209
+ { name: 'spec', type: 'Oas' },
210
+ { name: 'core', type: 'APICore' },
211
+ { name: 'authKeys', type: '(number | string)[][]', initializer: '[]' },
212
+ ]);
213
+
214
+ this.sdk.addConstructor({
215
+ statements: writer => {
216
+ writer.writeLine('this.spec = Oas.init(definition);');
217
+ writer.write('this.core = new APICore(this.spec, ').quote(this.userAgent).write(');');
218
+ return writer;
219
+ },
220
+ });
221
+
222
+ // Add our core API methods for controlling auth, servers, and various configurable abilities.
223
+ sdkSource.addInterface({
224
+ name: 'ConfigOptions',
225
+ properties: [
226
+ {
227
+ name: 'parseResponse',
228
+ type: 'boolean',
229
+ docs: [
230
+ wordWrap(
231
+ 'By default we parse the response based on the `Content-Type` header of the request. You can disable this functionality by negating this option.'
232
+ ),
233
+ ],
234
+ },
235
+ ],
236
+ });
237
+
238
+ this.sdk.addMethods([
239
+ {
240
+ name: 'config',
241
+ parameters: [{ name: 'config', type: 'ConfigOptions' }],
242
+ statements: writer => writer.writeLine('this.core.setConfig(config);'),
243
+ docs: [
244
+ {
245
+ description: writer =>
246
+ writer.writeLine(
247
+ wordWrap('Optionally configure various options, such as response parsing, that the SDK allows.')
248
+ ),
249
+ tags: [
250
+ { tagName: 'param', text: 'config Object of supported SDK options and toggles.' },
251
+ {
252
+ tagName: 'param',
253
+ text: 'config.parseResponse If responses are parsed according to its `Content-Type` header.',
254
+ },
255
+ ],
256
+ },
257
+ ],
258
+ },
259
+ {
260
+ name: 'auth',
261
+ parameters: [{ name: '...values', type: 'string[] | number[]' }],
262
+ statements: writer => {
263
+ writer.writeLine('this.core.setAuth(...values);');
264
+ writer.writeLine('return this;');
265
+ return writer;
266
+ },
267
+ docs: [
268
+ {
269
+ description: writer =>
270
+ writer.writeLine(
271
+ wordWrap(`If the API you're using requires authentication you can supply the required credentials through this method and the library will magically determine how they should be used within your API request.
272
+
273
+ With the exception of OpenID and MutualTLS, it supports all forms of authentication supported by the OpenAPI specification.
274
+
275
+ @example <caption>HTTP Basic auth</caption>
276
+ sdk.auth('username', 'password');
277
+
278
+ @example <caption>Bearer tokens (HTTP or OAuth 2)</caption>
279
+ sdk.auth('myBearerToken');
280
+
281
+ @example <caption>API Keys</caption>
282
+ sdk.auth('myApiKey');`)
283
+ ),
284
+ tags: [
285
+ { tagName: 'see', text: '{@link https://spec.openapis.org/oas/v3.0.3#fixed-fields-22}' },
286
+ { tagName: 'see', text: '{@link https://spec.openapis.org/oas/v3.1.0#fixed-fields-22}' },
287
+ {
288
+ tagName: 'param',
289
+ text: 'values Your auth credentials for the API; can specify up to two strings or numbers.',
290
+ },
291
+ ],
292
+ },
293
+ ],
294
+ },
295
+ {
296
+ name: 'server',
297
+ parameters: [
298
+ { name: 'url', type: 'string' },
299
+ { name: 'variables', initializer: '{}' },
300
+ ],
301
+ statements: writer => writer.writeLine('this.core.setServer(url, variables);'),
302
+ docs: [
303
+ {
304
+ description: writer =>
305
+ writer.writeLine(
306
+ wordWrap(`If the API you're using offers alternate server URLs, and server variables, you can tell the SDK which one to use with this method. To use it you can supply either one of the server URLs that are contained within the OpenAPI definition (along with any server variables), or you can pass it a fully qualified URL to use (that may or may not exist within the OpenAPI definition).
307
+
308
+ @example <caption>Server URL with server variables</caption>
309
+ sdk.server('https://{region}.api.example.com/{basePath}', {
310
+ name: 'eu',
311
+ basePath: 'v14',
312
+ });
313
+
314
+ @example <caption>Fully qualified server URL</caption>
315
+ sdk.server('https://eu.api.example.com/v14');`)
316
+ ),
317
+ tags: [
318
+ { tagName: 'param', text: 'url Server URL' },
319
+ { tagName: 'param', text: 'variables An object of variables to replace into the server URL.' },
320
+ ],
321
+ },
322
+ ],
323
+ },
324
+ ]);
325
+
326
+ // Add all common method accessors into the SDK.
327
+ Array.from(methods).forEach((method: string) => this.createGenericMethodAccessor(method));
328
+
329
+ // Add all available operation ID accessors into the SDK.
330
+ Object.entries(operations).forEach(([operationId, data]: [string, OperationTypeHousing]) => {
331
+ this.createOperationAccessor(data.operation, operationId, data.types.params, data.types.responses);
332
+ });
333
+
334
+ // @todo should all of these isolated into their own file outside of the main sdk class file?
335
+ // Add all known types that we're using into the SDK.
336
+ Array.from(this.types.values()).forEach(exp => {
337
+ sdkSource.addStatements(exp);
338
+ });
339
+
340
+ if (this.outputJS) {
341
+ return this.project
342
+ .emitToMemory()
343
+ .getFiles()
344
+ .map(sourceFile => ({
345
+ [path.basename(sourceFile.filePath)]: TSGenerator.formatter(sourceFile.text),
346
+ }))
347
+ .reduce((prev, next) => Object.assign(prev, next));
348
+ }
349
+
350
+ return [
351
+ ...this.project.getSourceFiles().map(sourceFile => ({
352
+ [sourceFile.getBaseName()]: TSGenerator.formatter(sourceFile.getFullText()),
353
+ })),
354
+
355
+ // Because we're returning the raw source files for TS generation we also need to separately
356
+ // emit out our declaration files so we can put those into a separate file in the installed
357
+ // SDK directory.
358
+ ...this.project
359
+ .emitToMemory({ emitOnlyDtsFiles: true })
360
+ .getFiles()
361
+ .map(sourceFile => ({
362
+ [path.basename(sourceFile.filePath)]: TSGenerator.formatter(sourceFile.text),
363
+ })),
364
+ ].reduce((prev, next) => Object.assign(prev, next));
365
+ }
366
+
367
+ /**
368
+ * Create a generic HTTP method accessor on the SDK.
369
+ *
370
+ * @param method
371
+ */
372
+ createGenericMethodAccessor(method: string) {
373
+ const parameters: OptionalKind<ParameterDeclarationStructure>[] = [{ name: 'path', type: 'string' }];
374
+ const docblock: OptionalKind<JSDocStructure> = {
375
+ description: writer => {
376
+ writer.writeLine(`Access any ${method} endpoint on your API.`);
377
+ return writer;
378
+ },
379
+ tags: [{ tagName: 'param', text: 'path API path to make a request against.' }],
380
+ };
381
+
382
+ // Method generic body + metadata parameters are always optional.
383
+ if (method !== 'get') {
384
+ parameters.push({ name: 'body', type: 'unknown', hasQuestionToken: true });
385
+ docblock.tags.push({ tagName: 'param', text: 'body Request body payload data.' });
386
+ }
387
+
388
+ parameters.push({ name: 'metadata', type: 'Record<string, unknown>', hasQuestionToken: true });
389
+ docblock.tags.push({
390
+ tagName: 'param',
391
+ text: 'metadata Object containing all path, query, header, and cookie parameters to supply.',
392
+ });
393
+
394
+ this.methodGenerics.set(
395
+ method,
396
+ this.sdk.addMethod({
397
+ name: method,
398
+ returnType: 'Promise<T>',
399
+ parameters,
400
+ typeParameters: ['T = unknown'],
401
+ docs: [docblock],
402
+ statements: writer => {
403
+ /**
404
+ * @example return this.core.fetch(path, 'get', body, metadata);
405
+ * @example return this.core.fetch(path, 'get', metadata);
406
+ */
407
+ const fetchStmt = writer.write('return this.core.fetch(path, ').quote(method).write(', ');
408
+
409
+ const fetchArgs = parameters.slice(1).map(p => p.name);
410
+ fetchArgs.forEach((arg, i) => {
411
+ fetchStmt.write(arg);
412
+ if (fetchArgs.length > 1 && i !== fetchArgs.length) {
413
+ fetchStmt.write(', ');
414
+ }
415
+ });
416
+
417
+ fetchStmt.write(');');
418
+
419
+ return fetchStmt;
420
+ },
421
+ })
422
+ );
423
+ }
424
+
425
+ /**
426
+ * Create operation accessors on the SDK.
427
+ *
428
+ * @param operation
429
+ * @param operationId
430
+ * @param paramTypes
431
+ * @param responseTypes
432
+ */
433
+ createOperationAccessor(
434
+ operation: Operation,
435
+ operationId: string,
436
+ paramTypes?: OperationTypeHousing['types']['params'],
437
+ responseTypes?: OperationTypeHousing['types']['responses']
438
+ ) {
439
+ const docblock: OptionalKind<JSDocStructure> = { tags: [] };
440
+ const summary = operation.getSummary();
441
+ const description = operation.getDescription();
442
+ if (summary || description) {
443
+ // To keep our generated docblocks clean we should only add the `@summary` tag if we've
444
+ // got both a summary and a description present on the operation, otherwise we can alternate
445
+ // what we surface the main docblock description.
446
+ docblock.description = writer => {
447
+ if (description) {
448
+ writer.writeLine(description);
449
+ } else if (summary) {
450
+ writer.writeLine(summary);
451
+ }
452
+
453
+ writer.newLineIfLastNot();
454
+ return writer;
455
+ };
456
+
457
+ if (summary && description) {
458
+ docblock.tags.push({ tagName: 'summary', text: summary });
459
+ }
460
+ }
461
+
462
+ let hasOptionalBody = false;
463
+ let hasOptionalMetadata = false;
464
+ const parameters: {
465
+ body?: OptionalKind<ParameterDeclarationStructure>;
466
+ metadata?: OptionalKind<ParameterDeclarationStructure>;
467
+ } = {};
468
+
469
+ if (paramTypes) {
470
+ // If an operation has a request body payload it will only ever have `body` or `formData`,
471
+ // never both, as these are determined upon the media type that's in use.
472
+ if (paramTypes.body || paramTypes.formData) {
473
+ hasOptionalBody = !operation.hasRequiredRequestBody();
474
+
475
+ parameters.body = {
476
+ name: 'body',
477
+ type: paramTypes.body
478
+ ? this.schemas.get(paramTypes.body).tsType
479
+ : this.schemas.get(paramTypes.formData).tsType,
480
+ hasQuestionToken: hasOptionalBody,
481
+ };
482
+ }
483
+
484
+ if (paramTypes.metadata) {
485
+ hasOptionalMetadata = !operation.hasRequiredParameters();
486
+
487
+ parameters.metadata = {
488
+ name: 'metadata',
489
+ type: this.schemas.get(paramTypes.metadata).tsType,
490
+ hasQuestionToken: hasOptionalMetadata,
491
+ };
492
+ }
493
+ }
494
+
495
+ let returnType = 'Promise<T>';
496
+ let typeParameters: (string | OptionalKind<TypeParameterDeclarationStructure>)[] = null;
497
+ if (responseTypes) {
498
+ returnType = `Promise<${Object.values(responseTypes)
499
+ .map(hash => this.schemas.get(hash).tsType)
500
+ .join(' | ')}>`;
501
+ } else {
502
+ // We should only add the `<T>` method typing if we don't have any response types present.
503
+ typeParameters = ['T = unknown'];
504
+ }
505
+
506
+ const operationIdAccessor = this.sdk.addMethod({
507
+ name: operationId,
508
+ typeParameters,
509
+ returnType,
510
+ docs: docblock ? [docblock] : null,
511
+ statements: writer => {
512
+ /**
513
+ * @example return this.core.fetch('/pet/findByStatus', 'get', body, metadata);
514
+ * @example return this.core.fetch('/pet/findByStatus', 'get', metadata);
515
+ */
516
+ const fetchStmt = writer
517
+ .write('return this.core.fetch(')
518
+ .quote(operation.path)
519
+ .write(', ')
520
+ .quote(operation.method);
521
+
522
+ const totalParams = Object.keys(parameters).length;
523
+ if (totalParams) {
524
+ Object.values(parameters).forEach((arg, i) => {
525
+ if (i === 0) {
526
+ fetchStmt.write(', ');
527
+ }
528
+
529
+ fetchStmt.write(arg.name);
530
+ if (totalParams > 1 && i !== totalParams) {
531
+ fetchStmt.write(', ');
532
+ }
533
+ });
534
+ }
535
+
536
+ fetchStmt.write(');');
537
+ return fetchStmt;
538
+ },
539
+ });
540
+
541
+ // If we have both body and metadata parameters but only body is optional we need to create
542
+ // a couple function overloads as Typescript doesn't let us have an optional method parameter
543
+ // come before one that's required.
544
+ //
545
+ // None of these accessor overloads will receive a docblock because the original will have
546
+ // that covered.
547
+ const shouldAddAltTypedOverloads = Object.keys(parameters).length === 2 && hasOptionalBody && !hasOptionalMetadata;
548
+ if (shouldAddAltTypedOverloads) {
549
+ // Create an overload that has both `body` and `metadata` parameters as required.
550
+ operationIdAccessor.addOverload({
551
+ typeParameters,
552
+ parameters: [
553
+ { ...parameters.body, hasQuestionToken: false },
554
+ { ...parameters.metadata, hasQuestionToken: false },
555
+ ],
556
+ returnType,
557
+ docs: docblock ? [docblock] : null,
558
+ });
559
+
560
+ // Create an overload that just has a single `metadata` parameter.
561
+ operationIdAccessor.addOverload({
562
+ typeParameters,
563
+ parameters: [{ ...parameters.metadata }],
564
+ returnType,
565
+ docs: docblock ? [docblock] : null,
566
+ });
567
+
568
+ // Create an overload that has both `body` and `metadata` parameters as optional. Even though
569
+ // our `metadata` parameter is actually required for this operation this is the only way we're
570
+ // able to have an optional `body` parameter be present before `metadata`.
571
+ //
572
+ // Thankfully our core fetch work in `api/dist/core` is able to do the proper determination to
573
+ // see if what the user is supplying is `metadata` or `body` content when they supply one or
574
+ // both.
575
+ operationIdAccessor.addParameters([
576
+ { ...parameters.body, hasQuestionToken: true },
577
+ { ...parameters.metadata, hasQuestionToken: true },
578
+ ]);
579
+ } else {
580
+ operationIdAccessor.addParameters(Object.values(parameters));
581
+ }
582
+
583
+ // Add a typed generic HTTP method overload for this operation.
584
+ if (this.methodGenerics.has(operation.method)) {
585
+ // If we created alternate overloads for the operation accessor then we need to do the same
586
+ // for its generic HTTP counterpart.
587
+ if (shouldAddAltTypedOverloads) {
588
+ // Create an overload that has both `body` and `metadata` parameters as required.
589
+ this.methodGenerics.get(operation.method).addOverload({
590
+ typeParameters,
591
+ parameters: [
592
+ { name: 'path', type: 'string' },
593
+ { ...parameters.body, hasQuestionToken: false },
594
+ { ...parameters.metadata, hasQuestionToken: false },
595
+ ],
596
+ returnType,
597
+ docs: docblock ? [docblock] : null,
598
+ });
599
+
600
+ // Create an overload that just has a single `metadata` parameter.
601
+ this.methodGenerics.get(operation.method).addOverload({
602
+ typeParameters,
603
+ parameters: [{ name: 'path', type: 'string' }, parameters.metadata],
604
+ returnType,
605
+ docs: docblock ? [docblock] : null,
606
+ });
607
+ } else {
608
+ this.methodGenerics.get(operation.method).addOverload({
609
+ typeParameters: responseTypes ? null : ['T = unknown'],
610
+ parameters: [{ name: 'path', type: 'string' }, ...Object.values(parameters)],
611
+ returnType,
612
+ docs: docblock ? [docblock] : null,
613
+ });
614
+ }
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Convert a JSON Schema object into a readily available TypeScript type or interface along with
620
+ * any `$ref` pointers that are in use and turn those into TS types too.
621
+ *
622
+ * Under the hood this uses https://npm.im/json-schema-to-typescript for all composition and
623
+ * conversion.
624
+ *
625
+ * @param schema
626
+ * @param name
627
+ */
628
+ async convertJSONSchemaToTypescript(schema: JSONSchema, name: string) {
629
+ // Though our JSON Schema type exposes JSONSchema4, which `json-schema-to-typescript` wants, it
630
+ // won't accept our custom union type of JSON Schema 4, JSON Schema 6, and JSON Schema 7.
631
+ const ts = await compile(schema as any, name, {
632
+ bannerComment: '',
633
+
634
+ // Running Prettier here for every JSON Schema object we're generating is way too slow so
635
+ // we're instead running it at the very end after we've constructed the SDK.
636
+ format: false,
637
+ });
638
+
639
+ let primaryType: string;
640
+ const tempProject = this.project.createSourceFile(`${name}.types.tmp.ts`, ts);
641
+ const declarations = tempProject.getExportedDeclarations();
642
+
643
+ Array.from(declarations.keys()).forEach(declarationName => {
644
+ if (!primaryType) {
645
+ primaryType = declarationName;
646
+ }
647
+
648
+ declarations.get(declarationName).forEach(declaration => {
649
+ this.types.set(declarationName, declaration.getText());
650
+ });
651
+ });
652
+
653
+ this.project.removeSourceFile(tempProject);
654
+
655
+ return {
656
+ primaryType,
657
+ };
658
+ }
659
+
660
+ /**
661
+ * Scour through the current OpenAPI definition and compile a store of every operation, along
662
+ * with every HTTP method that's in use, and their available TypeScript types that we can use,
663
+ * along with every HTTP method that's in use.
664
+ *
665
+ */
666
+ async loadOperationsAndMethods() {
667
+ const operations: Record</* operationId */ string, OperationTypeHousing> = {};
668
+ const methods = new Set();
669
+
670
+ // Prepare all of the schemas that we need to process for every operation within this API
671
+ // definition.
672
+ Object.entries(this.spec.getPaths()).forEach(([, ops]) => {
673
+ Object.entries(ops).forEach(([method, operation]: [HttpMethods, Operation]) => {
674
+ methods.add(method);
675
+
676
+ const operationId = operation.getOperationId();
677
+ const params = this.prepareParameterTypesForOperation(operation, operationId);
678
+ const responses = this.prepareResponseTypesForOperation(operation, operationId);
679
+
680
+ if (operation.hasOperationId()) {
681
+ operations[operation.getOperationId()] = {
682
+ types: {
683
+ params,
684
+ responses,
685
+ },
686
+ operation,
687
+ };
688
+ }
689
+ });
690
+ });
691
+
692
+ // Run through and convert every schema we need to use into TS types.
693
+ await Promise.all(
694
+ Array.from(this.schemas.entries()).map(async ([hash, { schema, name: schemaName }]) => {
695
+ const ts = await this.convertJSONSchemaToTypescript(schema as JSONSchema, schemaName);
696
+
697
+ this.schemas.set(hash, {
698
+ ...this.schemas.get(hash),
699
+ tsType: ts.primaryType,
700
+ });
701
+ })
702
+ );
703
+
704
+ return {
705
+ operations,
706
+ methods,
707
+ };
708
+ }
709
+
710
+ /**
711
+ * Compile the parameter (path, query, cookie, and header) schemas for an API operation into
712
+ * usable TypeScript types.
713
+ *
714
+ * @param operation
715
+ * @param operationId
716
+ */
717
+ prepareParameterTypesForOperation(operation: Operation, operationId: string) {
718
+ const schemas = operation.getParametersAsJsonSchema({
719
+ mergeIntoBodyAndMetadata: true,
720
+ retainDeprecatedProperties: true,
721
+ });
722
+
723
+ if (!schemas || !schemas.length) {
724
+ return false;
725
+ }
726
+
727
+ const res = schemas
728
+ .map(param => ({ [param.type]: param.schema }))
729
+ .reduce((prev, next) => Object.assign(prev, next));
730
+
731
+ return Object.entries(res)
732
+ .map(([paramType, schema]) => {
733
+ const schemaName = schema['x-readme-ref-name'] || `${operationId}_${paramType}_param`;
734
+ const hash = objectHash({
735
+ name: schemaName,
736
+ schema,
737
+ });
738
+
739
+ if (!this.schemas.has(hash)) {
740
+ this.schemas.set(hash, {
741
+ schema,
742
+ name: schemaName,
743
+ });
744
+ }
745
+
746
+ return {
747
+ [paramType]: hash,
748
+ };
749
+ })
750
+ .reduce((prev, next) => Object.assign(prev, next), {}) as Record<'body' | 'formData' | 'metadata', string>;
751
+ }
752
+
753
+ /**
754
+ * Compile the response schemas for an API operation into usable TypeScript types.
755
+ *
756
+ * @todo what does this do for a spec that has no responses?
757
+ * @param operation
758
+ * @param operationId
759
+ */
760
+ prepareResponseTypesForOperation(operation: Operation, operationId: string) {
761
+ const schemas = operation
762
+ .getResponseStatusCodes()
763
+ .map(status => {
764
+ const schema = operation.getResponseAsJsonSchema(status);
765
+ if (!schema) {
766
+ return false;
767
+ }
768
+
769
+ return {
770
+ [status]: schema.shift(),
771
+ };
772
+ })
773
+ .reduce((prev, next) => Object.assign(prev, next));
774
+
775
+ const res = Object.entries(schemas)
776
+ .map(([status, { schema }]) => {
777
+ const schemaName = schema['x-readme-ref-name'] || `${operationId}_Response_${status}`;
778
+ const hash = objectHash({
779
+ name: schemaName,
780
+ schema,
781
+ });
782
+
783
+ if (!this.schemas.has(hash)) {
784
+ this.schemas.set(hash, {
785
+ schema,
786
+ name: schemaName,
787
+ });
788
+ }
789
+
790
+ return {
791
+ [status]: hash,
792
+ };
793
+ })
794
+ .reduce((prev, next) => Object.assign(prev, next), {});
795
+
796
+ return Object.keys(res).length ? res : undefined;
797
+ }
798
+ }