api 5.0.0-beta.3 → 5.0.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 (46) hide show
  1. package/README.md +7 -7
  2. package/dist/bin.js +1 -1
  3. package/dist/cache.d.ts +37 -2
  4. package/dist/cache.js +7 -26
  5. package/dist/cli/codegen/index.d.ts +1 -1
  6. package/dist/cli/codegen/language.d.ts +1 -1
  7. package/dist/cli/codegen/language.js +13 -0
  8. package/dist/cli/codegen/languages/typescript/util.d.ts +21 -0
  9. package/dist/cli/codegen/languages/typescript/util.js +185 -0
  10. package/dist/cli/codegen/languages/typescript.d.ts +31 -38
  11. package/dist/cli/codegen/languages/typescript.js +390 -478
  12. package/dist/cli/commands/install.js +6 -6
  13. package/dist/cli/storage.js +2 -2
  14. package/dist/core/errors/fetchError.d.ts +12 -0
  15. package/dist/core/errors/fetchError.js +36 -0
  16. package/dist/core/index.d.ts +11 -3
  17. package/dist/core/index.js +36 -11
  18. package/dist/core/parseResponse.d.ts +6 -1
  19. package/dist/core/parseResponse.js +9 -3
  20. package/dist/core/prepareAuth.js +47 -18
  21. package/dist/core/prepareParams.d.ts +0 -3
  22. package/dist/core/prepareParams.js +81 -57
  23. package/dist/fetcher.js +3 -3
  24. package/dist/index.js +24 -40
  25. package/dist/packageInfo.d.ts +1 -1
  26. package/dist/packageInfo.js +1 -1
  27. package/package.json +28 -17
  28. package/src/bin.ts +2 -1
  29. package/src/cache.ts +8 -30
  30. package/src/cli/codegen/index.ts +1 -1
  31. package/src/cli/codegen/language.ts +18 -1
  32. package/src/cli/codegen/languages/typescript/util.ts +183 -0
  33. package/src/cli/codegen/languages/typescript.ts +340 -402
  34. package/src/cli/commands/install.ts +6 -8
  35. package/src/cli/storage.ts +3 -3
  36. package/src/core/errors/fetchError.ts +31 -0
  37. package/src/core/getJSONSchemaDefaults.ts +2 -1
  38. package/src/core/index.ts +52 -17
  39. package/src/core/parseResponse.ts +8 -2
  40. package/src/core/prepareAuth.ts +55 -31
  41. package/src/core/prepareParams.ts +88 -55
  42. package/src/fetcher.ts +4 -3
  43. package/src/index.ts +24 -32
  44. package/src/packageInfo.ts +1 -1
  45. package/src/typings.d.ts +0 -1
  46. package/tsconfig.json +1 -1
@@ -1,28 +1,21 @@
1
- import type Oas from 'oas';
2
- import type { Operation } from 'oas';
3
- import type { HttpMethods, JSONSchema, SchemaObject } from 'oas/dist/rmoas.types';
4
- import type {
5
- ClassDeclaration,
6
- JSDocStructure,
7
- MethodDeclaration,
8
- OptionalKind,
9
- ParameterDeclarationStructure,
10
- TypeParameterDeclarationStructure,
11
- VariableStatement,
12
- } from 'ts-morph';
13
- import type { Options as JSONSchemaToTypescriptOptions } from 'json-schema-to-typescript';
14
1
  import type Storage from '../../storage';
15
2
  import type { InstallerOptions } from '../language';
3
+ import type Oas from 'oas';
4
+ import type { Operation } from 'oas';
5
+ import type { HttpMethods, SchemaObject } from 'oas/dist/rmoas.types';
6
+ import type { ClassDeclaration, JSDocStructure, OptionalKind, ParameterDeclarationStructure } from 'ts-morph';
16
7
 
17
8
  import fs from 'fs';
18
9
  import path from 'path';
19
- import CodeGeneratorLanguage from '../language';
20
- import logger from '../../logger';
21
- import objectHash from 'object-hash';
22
- import { IndentationText, Project, QuoteKind, ScriptTarget, VariableDeclarationKind } from 'ts-morph';
23
- import { compile } from 'json-schema-to-typescript';
24
- import { format as prettier } from 'json-schema-to-typescript/dist/src/formatter';
10
+
25
11
  import execa from 'execa';
12
+ import setWith from 'lodash.setwith';
13
+ import { IndentationText, Project, QuoteKind, ScriptTarget, VariableDeclarationKind } from 'ts-morph';
14
+
15
+ import logger from '../../logger';
16
+ import CodeGeneratorLanguage from '../language';
17
+
18
+ import { docblockEscape, formatter, generateTypeName, wordWrap } from './typescript/util';
26
19
 
27
20
  export type TSGeneratorOptions = {
28
21
  outputJS?: boolean;
@@ -37,11 +30,6 @@ type OperationTypeHousing = {
37
30
  operation: Operation;
38
31
  };
39
32
 
40
- // https://www.30secondsofcode.org/js/s/word-wrap
41
- function wordWrap(str: string, max = 88) {
42
- return str.replace(new RegExp(`(?![^\\n]{1,${max}}$)([^\\n]{1,${max}})\\s`, 'g'), '$1\n');
43
- }
44
-
45
33
  export default class TSGenerator extends CodeGeneratorLanguage {
46
34
  project: Project;
47
35
 
@@ -53,21 +41,22 @@ export default class TSGenerator extends CodeGeneratorLanguage {
53
41
 
54
42
  files: Record<string, string>;
55
43
 
56
- methodGenerics: Map<string, MethodDeclaration>;
57
-
58
44
  sdk: ClassDeclaration;
59
45
 
60
- sdkExport: VariableStatement;
61
-
62
- schemas: Map<
46
+ schemas: Record<
63
47
  string,
64
- {
65
- schema: SchemaObject;
66
- name: string;
67
- tsType?: string;
68
- }
48
+ // Operation-level type
49
+ | {
50
+ body?: any;
51
+ metadata?: any;
52
+ response?: Record<string, any>;
53
+ }
54
+ // Wholesale collection of `$ref` pointer types
55
+ | Record<string, any>
69
56
  >;
70
57
 
58
+ usesHTTPMethodRangeInterface = false;
59
+
71
60
  constructor(spec: Oas, specPath: string, identifier: string, opts: TSGeneratorOptions = {}) {
72
61
  const options: { outputJS: boolean; compilerTarget: 'cjs' | 'esm' } = {
73
62
  outputJS: false,
@@ -83,10 +72,14 @@ export default class TSGenerator extends CodeGeneratorLanguage {
83
72
  super(spec, specPath, identifier);
84
73
 
85
74
  this.requiredPackages = {
86
- 'api@beta': {
75
+ api: {
87
76
  reason: "Required for the `api/dist/core` library that the codegen'd SDK uses for making requests.",
88
77
  url: 'https://npm.im/api',
89
78
  },
79
+ 'json-schema-to-ts': {
80
+ reason: 'Required for TypeScript type handling.',
81
+ url: 'https://npm.im/json-schema-to-ts',
82
+ },
90
83
  oas: {
91
84
  reason: 'Used within `api/dist/core` and is also loaded for TypeScript types.',
92
85
  url: 'https://npm.im/oas',
@@ -99,7 +92,9 @@ export default class TSGenerator extends CodeGeneratorLanguage {
99
92
  quoteKind: QuoteKind.Single,
100
93
  },
101
94
  compilerOptions: {
102
- declaration: true,
95
+ // If we're exporting a TypeScript SDK then we don't need to pollute the codegen directory
96
+ // with unnecessary declaration `.d.ts` files.
97
+ declaration: options.outputJS,
103
98
  outDir: 'dist',
104
99
  resolveJsonModule: true,
105
100
  target: options.compilerTarget === 'cjs' ? ScriptTarget.ES5 : ScriptTarget.ES2020,
@@ -118,18 +113,7 @@ export default class TSGenerator extends CodeGeneratorLanguage {
118
113
  this.outputJS = options.outputJS;
119
114
 
120
115
  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);
116
+ this.schemas = {};
133
117
  }
134
118
 
135
119
  async installer(storage: Storage, opts: InstallerOptions = {}): Promise<void> {
@@ -158,7 +142,7 @@ export default class TSGenerator extends CodeGeneratorLanguage {
158
142
  // This will install the installed SDK as a dependency within the current working directory,
159
143
  // adding `@api/<sdk identifier>` as a dependency there so you can load it with
160
144
  // `require('@api/<sdk identifier>)`.
161
- return execa('npm', [...npmInstall, storage.getIdentifierStorageDir()].filter(Boolean)).then(res => {
145
+ return execa('npm', [...npmInstall].filter(Boolean), { cwd: storage.getIdentifierStorageDir() }).then(res => {
162
146
  if (opts.dryRun) {
163
147
  (opts.logger ? opts.logger : logger)(res.command);
164
148
  (opts.logger ? opts.logger : logger)(res.stdout);
@@ -171,80 +155,129 @@ export default class TSGenerator extends CodeGeneratorLanguage {
171
155
  *
172
156
  */
173
157
  async generator() {
174
- const { operations, methods } = await this.loadOperationsAndMethods();
158
+ const sdkSource = this.createSourceFile();
159
+
160
+ if (Object.keys(this.schemas).length) {
161
+ this.createSchemasFile();
162
+ this.createTypesFile();
163
+
164
+ // Export all of our available types so they can be used in SDK implementations.
165
+ //
166
+ // We're exporting all of the types individually because TS has no way right now of allowing
167
+ // us to do `export type * from './types'` on a non-named entry.
168
+ //
169
+ // https://github.com/microsoft/TypeScript/issues/37238
170
+ const types = Array.from(this.types.keys());
171
+ types.sort();
172
+
173
+ sdkSource.addExportDeclarations([
174
+ {
175
+ isTypeOnly: true,
176
+ namedExports: types,
177
+ moduleSpecifier: './types',
178
+ },
179
+ ]);
180
+ } else {
181
+ // If we don't have any schemas then we shouldn't import a `types` file that doesn't exist.
182
+ sdkSource
183
+ .getImportDeclarations()
184
+ .find(id => id.getText() === "import type * as types from './types';")
185
+ .remove();
186
+ }
187
+
188
+ // If this SDK doesn't use the `HTTPMethodRange` interface for handling `2XX` response status
189
+ // codes then we should remove it from being imported.
190
+ if (!this.usesHTTPMethodRangeInterface) {
191
+ sdkSource
192
+ .getImportDeclarations()
193
+ .find(id => id.getText().includes('HTTPMethodRange'))
194
+ .replaceWithText("import type { ConfigOptions, FetchResponse } from 'api/dist/core'");
195
+ }
196
+
197
+ if (this.outputJS) {
198
+ return this.project
199
+ .emitToMemory()
200
+ .getFiles()
201
+ .map(sourceFile => {
202
+ const file = path.basename(sourceFile.filePath);
203
+ if (file === 'schemas.js' || file === 'types.js') {
204
+ // If we're generating a JS SDK then we don't need to generate these two files as the
205
+ // user will have `.d.ts` files for them instead.
206
+ return {};
207
+ }
208
+
209
+ let code = formatter(sourceFile.text);
210
+ if (file === 'index.js' && this.compilerTarget === 'cjs') {
211
+ /**
212
+ * There's an annoying quirk with `ts-morph` where if we're exporting a default export
213
+ * to a CJS environment, it'll export it as `exports.default`. Because we don't want
214
+ * folks in these environments to have to load their SDKs with
215
+ * `require('@api/sdk').default` we're overriding that here to change it to being the
216
+ * module exports.
217
+ *
218
+ * `ts-morph` unfortunately doesn't give us any options for programatically doing this
219
+ * so we need to resort to modifying the emitted JS code.
220
+ */
221
+ code = code
222
+ .replace(/Object\.defineProperty\(exports, '__esModule', { value: true }\);\n/, '')
223
+ .replace('exports.default = createSDK;', 'module.exports = createSDK;');
224
+ }
225
+
226
+ return {
227
+ [file]: code,
228
+ };
229
+ })
230
+ .reduce((prev, next) => Object.assign(prev, next));
231
+ }
232
+
233
+ return [
234
+ ...this.project.getSourceFiles().map(sourceFile => ({
235
+ [sourceFile.getBaseName()]: formatter(sourceFile.getFullText()),
236
+ })),
237
+
238
+ // Because we're returning the raw source files for TS generation we also need to separately
239
+ // emit out our declaration files so we can put those into a separate file in the installed
240
+ // SDK directory.
241
+ ...this.project
242
+ .emitToMemory({ emitOnlyDtsFiles: true })
243
+ .getFiles()
244
+ .map(sourceFile => ({
245
+ [path.basename(sourceFile.filePath)]: formatter(sourceFile.text),
246
+ })),
247
+ ].reduce((prev, next) => Object.assign(prev, next));
248
+ }
249
+
250
+ /**
251
+ * Create our main SDK source file.
252
+ *
253
+ */
254
+ createSourceFile() {
255
+ const { operations } = this.loadOperationsAndMethods();
175
256
 
176
- const sdkSource = this.project.createSourceFile('index.ts', '');
257
+ const sourceFile = this.project.createSourceFile('index.ts', '');
177
258
 
178
- sdkSource.addImportDeclarations([
259
+ sourceFile.addImportDeclarations([
260
+ // This import will be automatically removed later if the SDK ends up not having any types.
261
+ { defaultImport: 'type * as types', moduleSpecifier: './types' },
262
+ {
263
+ // `HTTPMethodRange` will be conditionally removed later if it ends up not being used.
264
+ defaultImport: 'type { ConfigOptions, FetchResponse, HTTPMethodRange }',
265
+ moduleSpecifier: 'api/dist/core',
266
+ },
179
267
  { defaultImport: 'Oas', moduleSpecifier: 'oas' },
180
268
  { defaultImport: 'APICore', moduleSpecifier: 'api/dist/core' },
181
269
  { defaultImport: 'definition', moduleSpecifier: this.specPath },
182
270
  ]);
183
271
 
184
272
  // @todo add TOS, License, info.* to a docblock at the top of the SDK.
185
- this.sdk = sdkSource.addClass({
273
+ this.sdk = sourceFile.addClass({
186
274
  name: 'SDK',
187
- });
188
-
189
- this.sdkExport = sdkSource.addVariableStatement({
190
- declarationKind: VariableDeclarationKind.Const,
191
- declarations: [
192
- {
193
- name: 'createSDK',
194
- initializer: writer => {
195
- // `ts-morph` doesn't have any way to cleanly create an IFEE.
196
- writer.writeLine('(() => { return new SDK(); })()');
197
- return writer;
198
- },
199
- },
275
+ properties: [
276
+ { name: 'spec', type: 'Oas' },
277
+ { name: 'core', type: 'APICore' },
200
278
  ],
201
279
  });
202
280
 
203
- /**
204
- * There's an annoying quirk with `ts-morph` where if we set the `createSDK` function to be the
205
- * default export with `isDefaultExport` then when we compile it to an ES5 target for CJS
206
- * environments it'll be exported as `export.default = createSDK`, which when you try to load it
207
- * you'll need to run `require('@api/sdk').default`.
208
- *
209
- * Instead here by plainly creating `createSDK` in the source file and then setting this export
210
- * assignment it'll export the SDK IFEE initializer as `module.exports = createSDK` so people
211
- * can cleanly load their SDK with `require('@api/sdk)`.
212
- *
213
- * A whole lot of debugging went into here to let people not have to worry about `.default`
214
- * messes. I hope it's worth it!
215
- */
216
- if (this.compilerTarget === 'cjs') {
217
- sdkSource.addExportAssignment({
218
- expression: 'createSDK',
219
- });
220
- } else {
221
- /**
222
- * Because `createSDK` above is an IFEE constant we can't use `setIsDefaultExport` on it due
223
- * to `ts-morph` not having great handling for IFEE's.
224
- *
225
- * If we were to call `setIsDefaultExport` on our IFEE to attempt to compile it as
226
- * `export default createSDK` then `ts-morph` hard crashes with a "Error replacing tree: The
227
- * children of the old and new trees were expected to have the same count" exception due to
228
- * it not being able properly handle IFEE's. It's for that reason that we need to manually
229
- * write a statement expression to set `createSDK` as the default export.
230
- *
231
- * Another quirk that this work avoids is there being an empty `export {};` at the very end
232
- * of our compiled `d.ts` declaration file. I'm not sure why it was being added, and it
233
- * didn't appear to be harming anything, but us manually creating this export statement
234
- * causes it to go away.
235
- *
236
- * Thankfully, fortunately, and curiously, these are all only problems in non-CJS compiled
237
- * targets. ¯\_(ツ)_/¯
238
- */
239
- sdkSource.addStatements('export default createSDK');
240
- }
241
-
242
- this.sdk.addProperties([
243
- { name: 'spec', type: 'Oas' },
244
- { name: 'core', type: 'APICore' },
245
- { name: 'authKeys', type: '(number | string)[][]', initializer: '[]' },
246
- ]);
247
-
248
281
  this.sdk.addConstructor({
249
282
  statements: writer => {
250
283
  writer.writeLine('this.spec = Oas.init(definition);');
@@ -254,21 +287,6 @@ export default class TSGenerator extends CodeGeneratorLanguage {
254
287
  });
255
288
 
256
289
  // Add our core API methods for controlling auth, servers, and various configurable abilities.
257
- sdkSource.addInterface({
258
- name: 'ConfigOptions',
259
- properties: [
260
- {
261
- name: 'parseResponse',
262
- type: 'boolean',
263
- docs: [
264
- wordWrap(
265
- 'By default we parse the response based on the `Content-Type` header of the request. You can disable this functionality by negating this option.'
266
- ),
267
- ],
268
- },
269
- ],
270
- });
271
-
272
290
  this.sdk.addMethods([
273
291
  {
274
292
  name: 'config',
@@ -277,14 +295,14 @@ export default class TSGenerator extends CodeGeneratorLanguage {
277
295
  docs: [
278
296
  {
279
297
  description: writer =>
280
- writer.writeLine(
281
- wordWrap('Optionally configure various options, such as response parsing, that the SDK allows.')
282
- ),
298
+ writer.writeLine(wordWrap('Optionally configure various options that the SDK allows.')),
283
299
  tags: [
284
300
  { tagName: 'param', text: 'config Object of supported SDK options and toggles.' },
285
301
  {
286
302
  tagName: 'param',
287
- text: 'config.parseResponse If responses are parsed according to its `Content-Type` header.',
303
+ text: wordWrap(
304
+ 'config.timeout Override the default `fetch` request timeout of 30 seconds. This number should be represented in milliseconds.'
305
+ ),
288
306
  },
289
307
  ],
290
308
  },
@@ -357,148 +375,102 @@ sdk.server('https://eu.api.example.com/v14');`)
357
375
  },
358
376
  ]);
359
377
 
360
- // Add all common method accessors into the SDK.
361
- Array.from(methods).forEach((method: string) => this.createGenericMethodAccessor(method));
362
-
363
378
  // Add all available operation ID accessors into the SDK.
364
379
  Object.entries(operations).forEach(([operationId, data]: [string, OperationTypeHousing]) => {
365
380
  this.createOperationAccessor(data.operation, operationId, data.types.params, data.types.responses);
366
381
  });
367
382
 
368
- // @todo should all of these isolated into their own file outside of the main sdk class file?
369
- // Add all known types that we're using into the SDK.
370
- Array.from(this.types.values()).forEach(exp => {
371
- /**
372
- * When `ts-morph` compiles declaration files when we're targeting CJS environments it creates
373
- * the default export as `export = _default` instead of `export default const _default`. This
374
- * causes TS to throw a TS2309 error for "An export assignment cannot be used in a module
375
- * with other exported elements" because our types and interfaces are also being exported and
376
- * the `export =` overrides those.
377
- *
378
- * Fixing this is, to be frank, a fucking HARD problem for a couple reasons:
379
- *
380
- * 1. Our JSON Schema types and interfaces are coming from `json-schema-to-typescript` and
381
- * that library exports its data a raw string containing multiple types and interfaces.
382
- * The only way we're able to capture and use them in our codegenerated SDK is because
383
- * we're ingesting that string into `ts-morph` and then using its APIs to extract exported
384
- * declarations (which are still strings) and then they're re-inserted into our main
385
- * source file here.
386
- * 2. Though `ts-morph` has APIs for adding type aliases and interfaces to a source file what
387
- * it doesn't have is the ability to pass in a string, or a `Writer` class that exposes,
388
- * to write raw strings to a type or an interface. If it did we'd be able to replace this
389
- * `addStatements` call with an `addTypeAlias` and `addInterface` call for each of our
390
- * JSON Schema schemas that we've got along with an `isExported` flag for `ts-morph` to
391
- * export it.
392
- *
393
- * Because neither of these are solvable problems right now we're instead opting to **not**
394
- * export types and interfaces from these SDKs. This isn't a great solution because it
395
- * /slightly/ reduces the usability of the TS codegen functionality but in order for the TS
396
- * declaration files that we generate to be valid this is the only option that we've got.
397
- *
398
- * However, that said, if somebody needs an interface or type exported they can export it
399
- * themselves in the SDK code that we compile for them.
400
- *
401
- * @fixme
402
- */
403
- sdkSource.addStatements(
404
- // All expressions coming out of `json-schema-to-typescript` are exported so by popping this
405
- // off we'll just be inserting plain interfaces and types into the SDK source.
406
- exp.substring('export '.length)
407
- );
383
+ // Export our SDK into the source file.
384
+ sourceFile.addVariableStatement({
385
+ declarationKind: VariableDeclarationKind.Const,
386
+ declarations: [
387
+ {
388
+ name: 'createSDK',
389
+ initializer: writer => {
390
+ // `ts-morph` doesn't have any way to cleanly create an IFEE.
391
+ writer.writeLine('(() => { return new SDK(); })()');
392
+ return writer;
393
+ },
394
+ },
395
+ ],
408
396
  });
409
397
 
410
- if (this.outputJS) {
411
- return this.project
412
- .emitToMemory()
413
- .getFiles()
414
- .map(sourceFile => ({
415
- [path.basename(sourceFile.filePath)]: TSGenerator.formatter(sourceFile.text),
416
- }))
417
- .reduce((prev, next) => Object.assign(prev, next));
418
- }
419
-
420
- return [
421
- ...this.project.getSourceFiles().map(sourceFile => ({
422
- [sourceFile.getBaseName()]: TSGenerator.formatter(sourceFile.getFullText()),
423
- })),
398
+ sourceFile.addExportAssignment({ isExportEquals: false, expression: 'createSDK' });
424
399
 
425
- // Because we're returning the raw source files for TS generation we also need to separately
426
- // emit out our declaration files so we can put those into a separate file in the installed
427
- // SDK directory.
428
- ...this.project
429
- .emitToMemory({ emitOnlyDtsFiles: true })
430
- .getFiles()
431
- .map(sourceFile => ({
432
- [path.basename(sourceFile.filePath)]: TSGenerator.formatter(sourceFile.text),
433
- })),
434
- ].reduce((prev, next) => Object.assign(prev, next));
400
+ return sourceFile;
435
401
  }
436
402
 
437
403
  /**
438
- * Create a generic HTTP method accessor on the SDK.
404
+ * Create our main schemas file. This is where all of the JSON Schema that our TypeScript typing
405
+ * infrastructure sources its data from. Without this there are no types.
439
406
  *
440
- * @param method
441
407
  */
442
- createGenericMethodAccessor(method: string) {
443
- const parameters: OptionalKind<ParameterDeclarationStructure>[] = [{ name: 'path', type: 'string' }];
444
- const docblock: OptionalKind<JSDocStructure> = {
445
- description: writer => {
446
- writer.writeLine(`Access any ${method.toUpperCase()} endpoint on your API.`);
447
- return writer;
448
- },
449
- tags: [{ tagName: 'param', text: 'path API path to make a request against.' }],
450
- };
408
+ createSchemasFile() {
409
+ const sourceFile = this.project.createSourceFile('schemas.ts', '');
451
410
 
452
- // Method generic body + metadata parameters are always optional.
453
- if (method !== 'get') {
454
- parameters.push({ name: 'body', type: 'unknown', hasQuestionToken: true });
455
- docblock.tags.push({ tagName: 'param', text: 'body Request body payload data.' });
456
- }
411
+ const sortedSchemas = new Map(Array.from(Object.entries(this.schemas)).sort());
457
412
 
458
- parameters.push({ name: 'metadata', type: 'Record<string, unknown>', hasQuestionToken: true });
459
- docblock.tags.push({
460
- tagName: 'param',
461
- text: 'metadata Object containing all path, query, header, and cookie parameters to supply.',
413
+ Array.from(sortedSchemas).forEach(([schemaName, schema]) => {
414
+ sourceFile.addVariableStatement({
415
+ declarationKind: VariableDeclarationKind.Const,
416
+ declarations: [
417
+ {
418
+ name: schemaName,
419
+ initializer: writer => {
420
+ /**
421
+ * This is the conversion prefix that we add to all `$ref` pointers we find in
422
+ * generated JSON Schema.
423
+ *
424
+ * Because the pointer name is a string we want to have it reference the schema
425
+ * constant we're adding into the codegen'd schema file. As there's no way, not even
426
+ * using `eval()` in this case, to convert a string to a constant we're prefixing
427
+ * them with this so we can later remove it and rewrite the value to a literal.
428
+ * eg. `'Pet'` becomes `Pet`.
429
+ *
430
+ * And because our TypeScript type name generator properly ignores `:`, this is safe
431
+ * to prepend to all generated type names.
432
+ */
433
+ let str = JSON.stringify(schema);
434
+ str = str.replace(/"::convert::([a-zA-Z_$\\d]*)"/g, '$1');
435
+
436
+ writer.writeLine(`${str} as const`);
437
+ return writer;
438
+ },
439
+ },
440
+ ],
441
+ });
462
442
  });
463
443
 
464
- this.methodGenerics.set(
465
- method,
466
- this.sdk.addMethod({
467
- name: method,
468
- returnType: 'Promise<T>',
469
- parameters,
470
- typeParameters: ['T = unknown'],
471
- docs: [docblock],
472
- statements: writer => {
473
- /**
474
- * @example return this.core.fetch(path, 'get', body, metadata);
475
- * @example return this.core.fetch(path, 'get', metadata);
476
- */
477
- const fetchStmt = writer.write('return this.core.fetch(path, ').quote(method).write(', ');
478
-
479
- const fetchArgs = parameters.slice(1).map(p => p.name);
480
- fetchArgs.forEach((arg, i) => {
481
- fetchStmt.write(arg);
482
- if (fetchArgs.length > 1 && i !== fetchArgs.length) {
483
- fetchStmt.write(', ');
484
- }
485
- });
444
+ sourceFile.addStatements(`export { ${Array.from(sortedSchemas.keys()).join(', ')} }`);
486
445
 
487
- fetchStmt.write(');');
446
+ return sourceFile;
447
+ }
488
448
 
489
- return fetchStmt;
490
- },
491
- })
492
- );
449
+ /**
450
+ * Create our main types file. This sources its data from the JSON Schema `schemas.ts` file and
451
+ * will re-export types to be used in TypeScript implementations and IDE intellisense. This
452
+ * typing work is functional with the `json-schema-to-ts` library.
453
+ *
454
+ * @see {@link https://npm.im/json-schema-to-ts}
455
+ */
456
+ createTypesFile() {
457
+ const sourceFile = this.project.createSourceFile('types.ts', '');
458
+
459
+ sourceFile.addImportDeclarations([
460
+ { defaultImport: 'type { FromSchema }', moduleSpecifier: 'json-schema-to-ts' },
461
+ { defaultImport: '* as schemas', moduleSpecifier: './schemas' },
462
+ ]);
463
+
464
+ Array.from(new Map(Array.from(this.types.entries()).sort())).forEach(([typeName, typeExpression]) => {
465
+ sourceFile.addTypeAlias({ isExported: true, name: typeName, type: typeExpression });
466
+ });
467
+
468
+ return sourceFile;
493
469
  }
494
470
 
495
471
  /**
496
472
  * Create operation accessors on the SDK.
497
473
  *
498
- * @param operation
499
- * @param operationId
500
- * @param paramTypes
501
- * @param responseTypes
502
474
  */
503
475
  createOperationAccessor(
504
476
  operation: Operation,
@@ -506,7 +478,7 @@ sdk.server('https://eu.api.example.com/v14');`)
506
478
  paramTypes?: OperationTypeHousing['types']['params'],
507
479
  responseTypes?: OperationTypeHousing['types']['responses']
508
480
  ) {
509
- const docblock: OptionalKind<JSDocStructure> = { tags: [] };
481
+ const docblock: OptionalKind<JSDocStructure> = {};
510
482
  const summary = operation.getSummary();
511
483
  const description = operation.getDescription();
512
484
  if (summary || description) {
@@ -515,9 +487,9 @@ sdk.server('https://eu.api.example.com/v14');`)
515
487
  // what we surface the main docblock description.
516
488
  docblock.description = writer => {
517
489
  if (description) {
518
- writer.writeLine(description);
490
+ writer.writeLine(docblockEscape(wordWrap(description)));
519
491
  } else if (summary) {
520
- writer.writeLine(summary);
492
+ writer.writeLine(docblockEscape(wordWrap(summary)));
521
493
  }
522
494
 
523
495
  writer.newLineIfLastNot();
@@ -525,7 +497,7 @@ sdk.server('https://eu.api.example.com/v14');`)
525
497
  };
526
498
 
527
499
  if (summary && description) {
528
- docblock.tags.push({ tagName: 'summary', text: summary });
500
+ docblock.tags = [{ tagName: 'summary', text: docblockEscape(wordWrap(summary)) }];
529
501
  }
530
502
  }
531
503
 
@@ -544,9 +516,7 @@ sdk.server('https://eu.api.example.com/v14');`)
544
516
 
545
517
  parameters.body = {
546
518
  name: 'body',
547
- type: paramTypes.body
548
- ? this.schemas.get(paramTypes.body).tsType
549
- : this.schemas.get(paramTypes.formData).tsType,
519
+ type: paramTypes.body ? paramTypes.body : paramTypes.formData,
550
520
  hasQuestionToken: hasOptionalBody,
551
521
  };
552
522
  }
@@ -556,28 +526,39 @@ sdk.server('https://eu.api.example.com/v14');`)
556
526
 
557
527
  parameters.metadata = {
558
528
  name: 'metadata',
559
- type: this.schemas.get(paramTypes.metadata).tsType,
529
+ type: paramTypes.metadata,
560
530
  hasQuestionToken: hasOptionalMetadata,
561
531
  };
562
532
  }
563
533
  }
564
534
 
565
- let returnType = 'Promise<T>';
566
- let typeParameters: (string | OptionalKind<TypeParameterDeclarationStructure>)[] = null;
535
+ let returnType = 'Promise<FetchResponse<number, unknown>>';
567
536
  if (responseTypes) {
568
- returnType = `Promise<${Object.values(responseTypes)
569
- .map(hash => this.schemas.get(hash).tsType)
537
+ returnType = `Promise<${Object.entries(responseTypes)
538
+ .map(([status, responseType]) => {
539
+ if (status.toLowerCase() === 'default') {
540
+ return `FetchResponse<number, ${responseType}>`;
541
+ } else if (status.length === 3 && status.toUpperCase().endsWith('XX')) {
542
+ const statusPrefix = status.slice(0, 1);
543
+ if (!Number.isInteger(Number(statusPrefix))) {
544
+ // If this matches the `_XX` format, but it isn't `{number}XX` then we can't handle
545
+ // it and should instead fall back to treating it as an unknown number.
546
+ return `FetchResponse<number, ${responseType}>`;
547
+ }
548
+
549
+ this.usesHTTPMethodRangeInterface = true;
550
+ return `FetchResponse<HTTPMethodRange<${statusPrefix}00, ${statusPrefix}99>, ${responseType}>`;
551
+ }
552
+
553
+ return `FetchResponse<${status}, ${responseType}>`;
554
+ })
570
555
  .join(' | ')}>`;
571
- } else {
572
- // We should only add the `<T>` method typing if we don't have any response types present.
573
- typeParameters = ['T = unknown'];
574
556
  }
575
557
 
576
558
  const operationIdAccessor = this.sdk.addMethod({
577
559
  name: operationId,
578
- typeParameters,
579
560
  returnType,
580
- docs: docblock ? [docblock] : null,
561
+ docs: Object.keys(docblock).length ? [docblock] : null,
581
562
  statements: writer => {
582
563
  /**
583
564
  * @example return this.core.fetch('/pet/findByStatus', 'get', body, metadata);
@@ -618,21 +599,19 @@ sdk.server('https://eu.api.example.com/v14');`)
618
599
  if (shouldAddAltTypedOverloads) {
619
600
  // Create an overload that has both `body` and `metadata` parameters as required.
620
601
  operationIdAccessor.addOverload({
621
- typeParameters,
622
602
  parameters: [
623
603
  { ...parameters.body, hasQuestionToken: false },
624
604
  { ...parameters.metadata, hasQuestionToken: false },
625
605
  ],
626
606
  returnType,
627
- docs: docblock ? [docblock] : null,
607
+ docs: Object.keys(docblock).length ? [docblock] : null,
628
608
  });
629
609
 
630
610
  // Create an overload that just has a single `metadata` parameter.
631
611
  operationIdAccessor.addOverload({
632
- typeParameters,
633
612
  parameters: [{ ...parameters.metadata }],
634
613
  returnType,
635
- docs: docblock ? [docblock] : null,
614
+ docs: Object.keys(docblock).length ? [docblock] : null,
636
615
  });
637
616
 
638
617
  // Create an overload that has both `body` and `metadata` parameters as optional. Even though
@@ -643,88 +622,20 @@ sdk.server('https://eu.api.example.com/v14');`)
643
622
  // see if what the user is supplying is `metadata` or `body` content when they supply one or
644
623
  // both.
645
624
  operationIdAccessor.addParameters([
646
- { ...parameters.body, hasQuestionToken: true },
625
+ {
626
+ ...parameters.body,
627
+ // Overloads have to be the most distilled version of the method so that's why we need to
628
+ // type `body` as either `body` or `metadata`. If we didn't do this, if `body` was a JSON
629
+ // Schema type that didn't allow `additionalProperties` then the implementation overload
630
+ // would throw type errors.
631
+ type: `${parameters.body.type} | ${parameters.metadata.type}`,
632
+ hasQuestionToken: true,
633
+ },
647
634
  { ...parameters.metadata, hasQuestionToken: true },
648
635
  ]);
649
636
  } else {
650
637
  operationIdAccessor.addParameters(Object.values(parameters));
651
638
  }
652
-
653
- // Add a typed generic HTTP method overload for this operation.
654
- if (this.methodGenerics.has(operation.method)) {
655
- // If we created alternate overloads for the operation accessor then we need to do the same
656
- // for its generic HTTP counterpart.
657
- if (shouldAddAltTypedOverloads) {
658
- // Create an overload that has both `body` and `metadata` parameters as required.
659
- this.methodGenerics.get(operation.method).addOverload({
660
- typeParameters,
661
- parameters: [
662
- { name: 'path', type: `'${operation.path}'` },
663
- { ...parameters.body, hasQuestionToken: false },
664
- { ...parameters.metadata, hasQuestionToken: false },
665
- ],
666
- returnType,
667
- docs: docblock ? [docblock] : null,
668
- });
669
-
670
- // Create an overload that just has a single `metadata` parameter.
671
- this.methodGenerics.get(operation.method).addOverload({
672
- typeParameters,
673
- parameters: [{ name: 'path', type: `'${operation.path}'` }, parameters.metadata],
674
- returnType,
675
- docs: docblock ? [docblock] : null,
676
- });
677
- } else {
678
- this.methodGenerics.get(operation.method).addOverload({
679
- typeParameters: responseTypes ? null : ['T = unknown'],
680
- parameters: [{ name: 'path', type: `'${operation.path}'` }, ...Object.values(parameters)],
681
- returnType,
682
- docs: docblock ? [docblock] : null,
683
- });
684
- }
685
- }
686
- }
687
-
688
- /**
689
- * Convert a JSON Schema object into a readily available TypeScript type or interface along with
690
- * any `$ref` pointers that are in use and turn those into TS types too.
691
- *
692
- * Under the hood this uses https://npm.im/json-schema-to-typescript for all composition and
693
- * conversion.
694
- *
695
- * @param schema
696
- * @param name
697
- */
698
- async convertJSONSchemaToTypescript(schema: JSONSchema, name: string) {
699
- // Though our JSON Schema type exposes JSONSchema4, which `json-schema-to-typescript` wants, it
700
- // won't accept our custom union type of JSON Schema 4, JSON Schema 6, and JSON Schema 7.
701
- const ts = await compile(schema as any, name, {
702
- bannerComment: '',
703
-
704
- // Running Prettier here for every JSON Schema object we're generating is way too slow so
705
- // we're instead running it at the very end after we've constructed the SDK.
706
- format: false,
707
- });
708
-
709
- let primaryType: string;
710
- const tempProject = this.project.createSourceFile(`${name}.types.tmp.ts`, ts);
711
- const declarations = tempProject.getExportedDeclarations();
712
-
713
- Array.from(declarations.keys()).forEach(declarationName => {
714
- if (!primaryType) {
715
- primaryType = declarationName;
716
- }
717
-
718
- declarations.get(declarationName).forEach(declaration => {
719
- this.types.set(declarationName, declaration.getText());
720
- });
721
- });
722
-
723
- this.project.removeSourceFile(tempProject);
724
-
725
- return {
726
- primaryType,
727
- };
728
639
  }
729
640
 
730
641
  /**
@@ -733,7 +644,7 @@ sdk.server('https://eu.api.example.com/v14');`)
733
644
  * along with every HTTP method that's in use.
734
645
  *
735
646
  */
736
- async loadOperationsAndMethods() {
647
+ loadOperationsAndMethods() {
737
648
  const operations: Record</* operationId */ string, OperationTypeHousing> = {};
738
649
  const methods = new Set();
739
650
 
@@ -749,32 +660,19 @@ sdk.server('https://eu.api.example.com/v14');`)
749
660
  camelCase: true,
750
661
  });
751
662
 
752
- const params = this.prepareParameterTypesForOperation(operation, operationId);
753
- const responses = this.prepareResponseTypesForOperation(operation, operationId);
754
-
755
- if (operation.hasOperationId()) {
756
- operations[operationId] = {
757
- types: {
758
- params,
759
- responses,
760
- },
761
- operation,
762
- };
763
- }
663
+ operations[operationId] = {
664
+ types: {
665
+ params: this.prepareParameterTypesForOperation(operation, operationId),
666
+ responses: this.prepareResponseTypesForOperation(operation, operationId),
667
+ },
668
+ operation,
669
+ };
764
670
  });
765
671
  });
766
672
 
767
- // Run through and convert every schema we need to use into TS types.
768
- await Promise.all(
769
- Array.from(this.schemas.entries()).map(async ([hash, { schema, name: schemaName }]) => {
770
- const ts = await this.convertJSONSchemaToTypescript(schema as JSONSchema, schemaName);
771
-
772
- this.schemas.set(hash, {
773
- ...this.schemas.get(hash),
774
- tsType: ts.primaryType,
775
- });
776
- })
777
- );
673
+ if (!Object.keys(operations).length) {
674
+ throw new Error('Sorry, this OpenAPI definition does not have any operation paths to generate an SDK for.');
675
+ }
778
676
 
779
677
  return {
780
678
  operations,
@@ -786,13 +684,24 @@ sdk.server('https://eu.api.example.com/v14');`)
786
684
  * Compile the parameter (path, query, cookie, and header) schemas for an API operation into
787
685
  * usable TypeScript types.
788
686
  *
789
- * @param operation
790
- * @param operationId
791
687
  */
792
688
  prepareParameterTypesForOperation(operation: Operation, operationId: string) {
793
- const schemas = operation.getParametersAsJsonSchema({
689
+ const schemas = operation.getParametersAsJSONSchema({
690
+ includeDiscriminatorMappingRefs: false,
794
691
  mergeIntoBodyAndMetadata: true,
795
692
  retainDeprecatedProperties: true,
693
+ transformer: (s: SchemaObject) => {
694
+ // As our schemas are dereferenced in the `oas` library we don't want to pollute our
695
+ // codegen'd schemas file with duplicate schemas.
696
+ if ('x-readme-ref-name' in s) {
697
+ const typeName = generateTypeName(s['x-readme-ref-name']);
698
+ this.addSchemaToExport(s, typeName, typeName);
699
+
700
+ return `::convert::${typeName}` as SchemaObject;
701
+ }
702
+
703
+ return s;
704
+ },
796
705
  });
797
706
 
798
707
  if (!schemas || !schemas.length) {
@@ -804,22 +713,22 @@ sdk.server('https://eu.api.example.com/v14');`)
804
713
  .reduce((prev, next) => Object.assign(prev, next));
805
714
 
806
715
  return Object.entries(res)
807
- .map(([paramType, schema]) => {
808
- const schemaName = schema['x-readme-ref-name'] || `${operationId}_${paramType}_param`;
809
- const hash = objectHash({
810
- name: schemaName,
811
- schema,
812
- });
813
-
814
- if (!this.schemas.has(hash)) {
815
- this.schemas.set(hash, {
816
- schema,
817
- name: schemaName,
818
- });
716
+ .map(([paramType, schema]: [string, string | unknown]) => {
717
+ let typeName;
718
+
719
+ if (typeof schema === 'string' && schema.startsWith('::convert::')) {
720
+ // If this schema is a string and has our conversion prefix then we've already created
721
+ // a type for it.
722
+ typeName = schema.replace('::convert::', '');
723
+ } else {
724
+ typeName = generateTypeName(operationId, paramType, 'param');
725
+ this.addSchemaToExport(schema, typeName, `${generateTypeName(operationId)}.${paramType}`);
819
726
  }
820
727
 
821
728
  return {
822
- [paramType]: hash,
729
+ // Types are prefixed with `types.` because that's how we're importing them from
730
+ // `types.d.ts`.
731
+ [paramType]: `types.${typeName}`,
823
732
  };
824
733
  })
825
734
  .reduce((prev, next) => Object.assign(prev, next), {}) as Record<'body' | 'formData' | 'metadata', string>;
@@ -828,9 +737,6 @@ sdk.server('https://eu.api.example.com/v14');`)
828
737
  /**
829
738
  * Compile the response schemas for an API operation into usable TypeScript types.
830
739
  *
831
- * @todo what does this do for a spec that has no responses?
832
- * @param operation
833
- * @param operationId
834
740
  */
835
741
  prepareResponseTypesForOperation(operation: Operation, operationId: string) {
836
742
  const responseStatusCodes = operation.getResponseStatusCodes();
@@ -840,7 +746,22 @@ sdk.server('https://eu.api.example.com/v14');`)
840
746
 
841
747
  const schemas = responseStatusCodes
842
748
  .map(status => {
843
- const schema = operation.getResponseAsJsonSchema(status);
749
+ const schema = operation.getResponseAsJSONSchema(status, {
750
+ includeDiscriminatorMappingRefs: false,
751
+ transformer: (s: SchemaObject) => {
752
+ // As our schemas are dereferenced in the `oas` library we don't want to pollute our
753
+ // codegen'd schemas file with duplicate schemas.
754
+ if ('x-readme-ref-name' in s) {
755
+ const typeName = generateTypeName(s['x-readme-ref-name']);
756
+ this.addSchemaToExport(s, typeName, `${typeName}`);
757
+
758
+ return `::convert::${typeName}` as SchemaObject;
759
+ }
760
+
761
+ return s;
762
+ },
763
+ });
764
+
844
765
  if (!schema) {
845
766
  return false;
846
767
  }
@@ -853,25 +774,42 @@ sdk.server('https://eu.api.example.com/v14');`)
853
774
 
854
775
  const res = Object.entries(schemas)
855
776
  .map(([status, { schema }]) => {
856
- const schemaName = schema['x-readme-ref-name'] || `${operationId}_Response_${status}`;
857
- const hash = objectHash({
858
- name: schemaName,
859
- schema,
860
- });
861
-
862
- if (!this.schemas.has(hash)) {
863
- this.schemas.set(hash, {
864
- schema,
865
- name: schemaName,
866
- });
777
+ let typeName;
778
+
779
+ if (typeof schema === 'string' && schema.startsWith('::convert::')) {
780
+ // If this schema is a string and has our conversion prefix then we've already created
781
+ // a type for it.
782
+ typeName = schema.replace('::convert::', '');
783
+ } else {
784
+ typeName = generateTypeName(operationId, 'response', status);
785
+
786
+ // Because `status` will usually be a number here we need to set the pointer for it
787
+ // within an `[]` as if we do `FromSchema<typeof schemas.operation.response.200>`,
788
+ // TypeScript will throw a compilation error.
789
+ this.addSchemaToExport(schema, typeName, `${generateTypeName(operationId)}.response['${status}']`);
867
790
  }
868
791
 
869
792
  return {
870
- [status]: hash,
793
+ // Types are prefixed with `types.` because that's how we're importing them from
794
+ // `types.d.ts`.
795
+ [status]: `types.${typeName}`,
871
796
  };
872
797
  })
873
798
  .reduce((prev, next) => Object.assign(prev, next), {});
874
799
 
875
800
  return Object.keys(res).length ? res : undefined;
876
801
  }
802
+
803
+ /**
804
+ * Add a given schema into our schema dataset that we'll be be exporting as types.
805
+ *
806
+ */
807
+ addSchemaToExport(schema: any, typeName: string, pointer: string) {
808
+ if (this.types.has(typeName)) {
809
+ return;
810
+ }
811
+
812
+ setWith(this.schemas, pointer, schema, Object);
813
+ this.types.set(typeName, `FromSchema<typeof schemas.${pointer}>`);
814
+ }
877
815
  }