api 5.0.0-beta.2 → 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 (50) hide show
  1. package/README.md +7 -8
  2. package/dist/bin.js +1 -1
  3. package/dist/cache.d.ts +38 -3
  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 +36 -41
  11. package/dist/cli/codegen/languages/typescript.js +394 -414
  12. package/dist/cli/commands/install.js +6 -6
  13. package/dist/cli/storage.d.ts +1 -1
  14. package/dist/cli/storage.js +2 -2
  15. package/dist/core/errors/fetchError.d.ts +12 -0
  16. package/dist/core/errors/fetchError.js +36 -0
  17. package/dist/core/getJSONSchemaDefaults.d.ts +1 -1
  18. package/dist/core/index.d.ts +12 -4
  19. package/dist/core/index.js +36 -11
  20. package/dist/core/parseResponse.d.ts +6 -1
  21. package/dist/core/parseResponse.js +9 -3
  22. package/dist/core/prepareAuth.js +47 -18
  23. package/dist/core/prepareParams.d.ts +0 -3
  24. package/dist/core/prepareParams.js +102 -41
  25. package/dist/fetcher.d.ts +1 -1
  26. package/dist/fetcher.js +3 -3
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +24 -40
  29. package/dist/packageInfo.d.ts +1 -1
  30. package/dist/packageInfo.js +1 -1
  31. package/package.json +31 -17
  32. package/src/bin.ts +2 -1
  33. package/src/cache.ts +9 -31
  34. package/src/cli/codegen/index.ts +1 -1
  35. package/src/cli/codegen/language.ts +18 -1
  36. package/src/cli/codegen/languages/typescript/util.ts +183 -0
  37. package/src/cli/codegen/languages/typescript.ts +348 -340
  38. package/src/cli/commands/install.ts +6 -8
  39. package/src/cli/storage.ts +4 -4
  40. package/src/core/errors/fetchError.ts +31 -0
  41. package/src/core/getJSONSchemaDefaults.ts +3 -2
  42. package/src/core/index.ts +53 -18
  43. package/src/core/parseResponse.ts +8 -2
  44. package/src/core/prepareAuth.ts +55 -31
  45. package/src/core/prepareParams.ts +112 -41
  46. package/src/fetcher.ts +5 -4
  47. package/src/index.ts +24 -32
  48. package/src/packageInfo.ts +1 -1
  49. package/src/typings.d.ts +0 -1
  50. package/tsconfig.json +1 -1
@@ -1,27 +1,26 @@
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
1
  import type Storage from '../../storage';
14
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';
15
7
 
16
8
  import fs from 'fs';
17
9
  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';
10
+
24
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';
19
+
20
+ export type TSGeneratorOptions = {
21
+ outputJS?: boolean;
22
+ compilerTarget?: 'cjs' | 'esm';
23
+ };
25
24
 
26
25
  type OperationTypeHousing = {
27
26
  types: {
@@ -31,11 +30,6 @@ type OperationTypeHousing = {
31
30
  operation: Operation;
32
31
  };
33
32
 
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
33
  export default class TSGenerator extends CodeGeneratorLanguage {
40
34
  project: Project;
41
35
 
@@ -47,28 +41,23 @@ export default class TSGenerator extends CodeGeneratorLanguage {
47
41
 
48
42
  files: Record<string, string>;
49
43
 
50
- methodGenerics: Map<string, MethodDeclaration>;
51
-
52
44
  sdk: ClassDeclaration;
53
45
 
54
- schemas: Map<
46
+ schemas: Record<
55
47
  string,
56
- {
57
- schema: SchemaObject;
58
- name: string;
59
- tsType?: string;
60
- }
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>
61
56
  >;
62
57
 
63
- constructor(
64
- spec: Oas,
65
- specPath: string,
66
- identifier: string,
67
- opts: {
68
- outputJS?: boolean;
69
- compilerTarget?: 'cjs' | 'esm';
70
- } = {}
71
- ) {
58
+ usesHTTPMethodRangeInterface = false;
59
+
60
+ constructor(spec: Oas, specPath: string, identifier: string, opts: TSGeneratorOptions = {}) {
72
61
  const options: { outputJS: boolean; compilerTarget: 'cjs' | 'esm' } = {
73
62
  outputJS: false,
74
63
  compilerTarget: 'cjs',
@@ -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,10 +92,12 @@ 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,
98
+ outDir: 'dist',
103
99
  resolveJsonModule: true,
104
100
  target: options.compilerTarget === 'cjs' ? ScriptTarget.ES5 : ScriptTarget.ES2020,
105
- outDir: 'dist',
106
101
 
107
102
  // If we're compiling to a CJS target then we need to include this compiler option
108
103
  // otherwise TS will attempt to load our `openapi.json` import with a `.default` property
@@ -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,46 +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();
175
163
 
176
- const sdkSource = this.project.createSourceFile('index.ts', '');
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();
177
172
 
178
- sdkSource.addImportDeclarations([
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();
256
+
257
+ const sourceFile = this.project.createSourceFile('index.ts', '');
258
+
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',
275
+ properties: [
276
+ { name: 'spec', type: 'Oas' },
277
+ { name: 'core', type: 'APICore' },
278
+ ],
187
279
  });
188
280
 
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
281
  this.sdk.addConstructor({
215
282
  statements: writer => {
216
283
  writer.writeLine('this.spec = Oas.init(definition);');
@@ -220,21 +287,6 @@ export default class TSGenerator extends CodeGeneratorLanguage {
220
287
  });
221
288
 
222
289
  // 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
290
  this.sdk.addMethods([
239
291
  {
240
292
  name: 'config',
@@ -243,14 +295,14 @@ export default class TSGenerator extends CodeGeneratorLanguage {
243
295
  docs: [
244
296
  {
245
297
  description: writer =>
246
- writer.writeLine(
247
- wordWrap('Optionally configure various options, such as response parsing, that the SDK allows.')
248
- ),
298
+ writer.writeLine(wordWrap('Optionally configure various options that the SDK allows.')),
249
299
  tags: [
250
300
  { tagName: 'param', text: 'config Object of supported SDK options and toggles.' },
251
301
  {
252
302
  tagName: 'param',
253
- 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
+ ),
254
306
  },
255
307
  ],
256
308
  },
@@ -323,112 +375,102 @@ sdk.server('https://eu.api.example.com/v14');`)
323
375
  },
324
376
  ]);
325
377
 
326
- // Add all common method accessors into the SDK.
327
- Array.from(methods).forEach((method: string) => this.createGenericMethodAccessor(method));
328
-
329
378
  // Add all available operation ID accessors into the SDK.
330
379
  Object.entries(operations).forEach(([operationId, data]: [string, OperationTypeHousing]) => {
331
380
  this.createOperationAccessor(data.operation, operationId, data.types.params, data.types.responses);
332
381
  });
333
382
 
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);
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
+ ],
338
396
  });
339
397
 
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
- })),
398
+ sourceFile.addExportAssignment({ isExportEquals: false, expression: 'createSDK' });
354
399
 
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));
400
+ return sourceFile;
365
401
  }
366
402
 
367
403
  /**
368
- * 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.
369
406
  *
370
- * @param method
371
407
  */
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.toUpperCase()} endpoint on your API.`);
377
- return writer;
378
- },
379
- tags: [{ tagName: 'param', text: 'path API path to make a request against.' }],
380
- };
408
+ createSchemasFile() {
409
+ const sourceFile = this.project.createSourceFile('schemas.ts', '');
381
410
 
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
- }
411
+ const sortedSchemas = new Map(Array.from(Object.entries(this.schemas)).sort());
387
412
 
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.',
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
+ });
392
442
  });
393
443
 
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
- });
444
+ sourceFile.addStatements(`export { ${Array.from(sortedSchemas.keys()).join(', ')} }`);
416
445
 
417
- fetchStmt.write(');');
446
+ return sourceFile;
447
+ }
418
448
 
419
- return fetchStmt;
420
- },
421
- })
422
- );
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;
423
469
  }
424
470
 
425
471
  /**
426
472
  * Create operation accessors on the SDK.
427
473
  *
428
- * @param operation
429
- * @param operationId
430
- * @param paramTypes
431
- * @param responseTypes
432
474
  */
433
475
  createOperationAccessor(
434
476
  operation: Operation,
@@ -436,7 +478,7 @@ sdk.server('https://eu.api.example.com/v14');`)
436
478
  paramTypes?: OperationTypeHousing['types']['params'],
437
479
  responseTypes?: OperationTypeHousing['types']['responses']
438
480
  ) {
439
- const docblock: OptionalKind<JSDocStructure> = { tags: [] };
481
+ const docblock: OptionalKind<JSDocStructure> = {};
440
482
  const summary = operation.getSummary();
441
483
  const description = operation.getDescription();
442
484
  if (summary || description) {
@@ -445,9 +487,9 @@ sdk.server('https://eu.api.example.com/v14');`)
445
487
  // what we surface the main docblock description.
446
488
  docblock.description = writer => {
447
489
  if (description) {
448
- writer.writeLine(description);
490
+ writer.writeLine(docblockEscape(wordWrap(description)));
449
491
  } else if (summary) {
450
- writer.writeLine(summary);
492
+ writer.writeLine(docblockEscape(wordWrap(summary)));
451
493
  }
452
494
 
453
495
  writer.newLineIfLastNot();
@@ -455,7 +497,7 @@ sdk.server('https://eu.api.example.com/v14');`)
455
497
  };
456
498
 
457
499
  if (summary && description) {
458
- docblock.tags.push({ tagName: 'summary', text: summary });
500
+ docblock.tags = [{ tagName: 'summary', text: docblockEscape(wordWrap(summary)) }];
459
501
  }
460
502
  }
461
503
 
@@ -474,9 +516,7 @@ sdk.server('https://eu.api.example.com/v14');`)
474
516
 
475
517
  parameters.body = {
476
518
  name: 'body',
477
- type: paramTypes.body
478
- ? this.schemas.get(paramTypes.body).tsType
479
- : this.schemas.get(paramTypes.formData).tsType,
519
+ type: paramTypes.body ? paramTypes.body : paramTypes.formData,
480
520
  hasQuestionToken: hasOptionalBody,
481
521
  };
482
522
  }
@@ -486,28 +526,39 @@ sdk.server('https://eu.api.example.com/v14');`)
486
526
 
487
527
  parameters.metadata = {
488
528
  name: 'metadata',
489
- type: this.schemas.get(paramTypes.metadata).tsType,
529
+ type: paramTypes.metadata,
490
530
  hasQuestionToken: hasOptionalMetadata,
491
531
  };
492
532
  }
493
533
  }
494
534
 
495
- let returnType = 'Promise<T>';
496
- let typeParameters: (string | OptionalKind<TypeParameterDeclarationStructure>)[] = null;
535
+ let returnType = 'Promise<FetchResponse<number, unknown>>';
497
536
  if (responseTypes) {
498
- returnType = `Promise<${Object.values(responseTypes)
499
- .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
+ })
500
555
  .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
556
  }
505
557
 
506
558
  const operationIdAccessor = this.sdk.addMethod({
507
559
  name: operationId,
508
- typeParameters,
509
560
  returnType,
510
- docs: docblock ? [docblock] : null,
561
+ docs: Object.keys(docblock).length ? [docblock] : null,
511
562
  statements: writer => {
512
563
  /**
513
564
  * @example return this.core.fetch('/pet/findByStatus', 'get', body, metadata);
@@ -548,21 +599,19 @@ sdk.server('https://eu.api.example.com/v14');`)
548
599
  if (shouldAddAltTypedOverloads) {
549
600
  // Create an overload that has both `body` and `metadata` parameters as required.
550
601
  operationIdAccessor.addOverload({
551
- typeParameters,
552
602
  parameters: [
553
603
  { ...parameters.body, hasQuestionToken: false },
554
604
  { ...parameters.metadata, hasQuestionToken: false },
555
605
  ],
556
606
  returnType,
557
- docs: docblock ? [docblock] : null,
607
+ docs: Object.keys(docblock).length ? [docblock] : null,
558
608
  });
559
609
 
560
610
  // Create an overload that just has a single `metadata` parameter.
561
611
  operationIdAccessor.addOverload({
562
- typeParameters,
563
612
  parameters: [{ ...parameters.metadata }],
564
613
  returnType,
565
- docs: docblock ? [docblock] : null,
614
+ docs: Object.keys(docblock).length ? [docblock] : null,
566
615
  });
567
616
 
568
617
  // Create an overload that has both `body` and `metadata` parameters as optional. Even though
@@ -573,88 +622,20 @@ sdk.server('https://eu.api.example.com/v14');`)
573
622
  // see if what the user is supplying is `metadata` or `body` content when they supply one or
574
623
  // both.
575
624
  operationIdAccessor.addParameters([
576
- { ...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
+ },
577
634
  { ...parameters.metadata, hasQuestionToken: true },
578
635
  ]);
579
636
  } else {
580
637
  operationIdAccessor.addParameters(Object.values(parameters));
581
638
  }
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: `'${operation.path}'` },
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: `'${operation.path}'` }, 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: `'${operation.path}'` }, ...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
639
  }
659
640
 
660
641
  /**
@@ -663,7 +644,7 @@ sdk.server('https://eu.api.example.com/v14');`)
663
644
  * along with every HTTP method that's in use.
664
645
  *
665
646
  */
666
- async loadOperationsAndMethods() {
647
+ loadOperationsAndMethods() {
667
648
  const operations: Record</* operationId */ string, OperationTypeHousing> = {};
668
649
  const methods = new Set();
669
650
 
@@ -679,32 +660,19 @@ sdk.server('https://eu.api.example.com/v14');`)
679
660
  camelCase: true,
680
661
  });
681
662
 
682
- const params = this.prepareParameterTypesForOperation(operation, operationId);
683
- const responses = this.prepareResponseTypesForOperation(operation, operationId);
684
-
685
- if (operation.hasOperationId()) {
686
- operations[operationId] = {
687
- types: {
688
- params,
689
- responses,
690
- },
691
- operation,
692
- };
693
- }
663
+ operations[operationId] = {
664
+ types: {
665
+ params: this.prepareParameterTypesForOperation(operation, operationId),
666
+ responses: this.prepareResponseTypesForOperation(operation, operationId),
667
+ },
668
+ operation,
669
+ };
694
670
  });
695
671
  });
696
672
 
697
- // Run through and convert every schema we need to use into TS types.
698
- await Promise.all(
699
- Array.from(this.schemas.entries()).map(async ([hash, { schema, name: schemaName }]) => {
700
- const ts = await this.convertJSONSchemaToTypescript(schema as JSONSchema, schemaName);
701
-
702
- this.schemas.set(hash, {
703
- ...this.schemas.get(hash),
704
- tsType: ts.primaryType,
705
- });
706
- })
707
- );
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
+ }
708
676
 
709
677
  return {
710
678
  operations,
@@ -716,13 +684,24 @@ sdk.server('https://eu.api.example.com/v14');`)
716
684
  * Compile the parameter (path, query, cookie, and header) schemas for an API operation into
717
685
  * usable TypeScript types.
718
686
  *
719
- * @param operation
720
- * @param operationId
721
687
  */
722
688
  prepareParameterTypesForOperation(operation: Operation, operationId: string) {
723
- const schemas = operation.getParametersAsJsonSchema({
689
+ const schemas = operation.getParametersAsJSONSchema({
690
+ includeDiscriminatorMappingRefs: false,
724
691
  mergeIntoBodyAndMetadata: true,
725
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
+ },
726
705
  });
727
706
 
728
707
  if (!schemas || !schemas.length) {
@@ -734,22 +713,22 @@ sdk.server('https://eu.api.example.com/v14');`)
734
713
  .reduce((prev, next) => Object.assign(prev, next));
735
714
 
736
715
  return Object.entries(res)
737
- .map(([paramType, schema]) => {
738
- const schemaName = schema['x-readme-ref-name'] || `${operationId}_${paramType}_param`;
739
- const hash = objectHash({
740
- name: schemaName,
741
- schema,
742
- });
743
-
744
- if (!this.schemas.has(hash)) {
745
- this.schemas.set(hash, {
746
- schema,
747
- name: schemaName,
748
- });
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}`);
749
726
  }
750
727
 
751
728
  return {
752
- [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}`,
753
732
  };
754
733
  })
755
734
  .reduce((prev, next) => Object.assign(prev, next), {}) as Record<'body' | 'formData' | 'metadata', string>;
@@ -758,9 +737,6 @@ sdk.server('https://eu.api.example.com/v14');`)
758
737
  /**
759
738
  * Compile the response schemas for an API operation into usable TypeScript types.
760
739
  *
761
- * @todo what does this do for a spec that has no responses?
762
- * @param operation
763
- * @param operationId
764
740
  */
765
741
  prepareResponseTypesForOperation(operation: Operation, operationId: string) {
766
742
  const responseStatusCodes = operation.getResponseStatusCodes();
@@ -770,7 +746,22 @@ sdk.server('https://eu.api.example.com/v14');`)
770
746
 
771
747
  const schemas = responseStatusCodes
772
748
  .map(status => {
773
- 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
+
774
765
  if (!schema) {
775
766
  return false;
776
767
  }
@@ -783,25 +774,42 @@ sdk.server('https://eu.api.example.com/v14');`)
783
774
 
784
775
  const res = Object.entries(schemas)
785
776
  .map(([status, { schema }]) => {
786
- const schemaName = schema['x-readme-ref-name'] || `${operationId}_Response_${status}`;
787
- const hash = objectHash({
788
- name: schemaName,
789
- schema,
790
- });
791
-
792
- if (!this.schemas.has(hash)) {
793
- this.schemas.set(hash, {
794
- schema,
795
- name: schemaName,
796
- });
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}']`);
797
790
  }
798
791
 
799
792
  return {
800
- [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}`,
801
796
  };
802
797
  })
803
798
  .reduce((prev, next) => Object.assign(prev, next), {});
804
799
 
805
800
  return Object.keys(res).length ? res : undefined;
806
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
+ }
807
815
  }