api 7.0.0-alpha.6 → 7.0.0-beta.1

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 (58) hide show
  1. package/dist/codegen/codegenerator.d.ts +9 -11
  2. package/dist/codegen/codegenerator.d.ts.map +1 -1
  3. package/dist/codegen/codegenerator.js +11 -0
  4. package/dist/codegen/codegenerator.js.map +1 -1
  5. package/dist/codegen/factory.d.ts +19 -3
  6. package/dist/codegen/factory.d.ts.map +1 -1
  7. package/dist/codegen/factory.js +12 -5
  8. package/dist/codegen/factory.js.map +1 -1
  9. package/dist/codegen/languages/typescript/index.d.ts +8 -2
  10. package/dist/codegen/languages/typescript/index.d.ts.map +1 -1
  11. package/dist/codegen/languages/typescript/index.js +69 -25
  12. package/dist/codegen/languages/typescript/index.js.map +1 -1
  13. package/dist/commands/index.d.ts +2 -0
  14. package/dist/commands/index.d.ts.map +1 -1
  15. package/dist/commands/index.js +4 -0
  16. package/dist/commands/index.js.map +1 -1
  17. package/dist/commands/install.d.ts.map +1 -1
  18. package/dist/commands/install.js +30 -20
  19. package/dist/commands/install.js.map +1 -1
  20. package/dist/commands/list.d.ts +4 -0
  21. package/dist/commands/list.d.ts.map +1 -0
  22. package/dist/commands/list.js +37 -0
  23. package/dist/commands/list.js.map +1 -0
  24. package/dist/commands/uninstall.d.ts +4 -0
  25. package/dist/commands/uninstall.d.ts.map +1 -0
  26. package/dist/commands/uninstall.js +72 -0
  27. package/dist/commands/uninstall.js.map +1 -0
  28. package/dist/fetcher.d.ts +5 -0
  29. package/dist/fetcher.d.ts.map +1 -1
  30. package/dist/fetcher.js +5 -0
  31. package/dist/fetcher.js.map +1 -1
  32. package/dist/lockfileSchema.d.ts +125 -0
  33. package/dist/lockfileSchema.d.ts.map +1 -0
  34. package/dist/lockfileSchema.js +78 -0
  35. package/dist/lockfileSchema.js.map +1 -0
  36. package/dist/packageInfo.d.ts +1 -1
  37. package/dist/packageInfo.d.ts.map +1 -1
  38. package/dist/packageInfo.js +1 -1
  39. package/dist/packageInfo.js.map +1 -1
  40. package/dist/storage.d.ts +76 -61
  41. package/dist/storage.d.ts.map +1 -1
  42. package/dist/storage.js +92 -27
  43. package/dist/storage.js.map +1 -1
  44. package/package.json +26 -17
  45. package/schema.json +69 -0
  46. package/src/bin.ts +0 -21
  47. package/src/codegen/codegenerator.ts +0 -75
  48. package/src/codegen/factory.ts +0 -23
  49. package/src/codegen/languages/typescript/index.ts +0 -984
  50. package/src/codegen/languages/typescript/util.ts +0 -174
  51. package/src/commands/index.ts +0 -5
  52. package/src/commands/install.ts +0 -185
  53. package/src/fetcher.ts +0 -140
  54. package/src/lib/prompt.ts +0 -29
  55. package/src/logger.ts +0 -10
  56. package/src/packageInfo.ts +0 -3
  57. package/src/storage.ts +0 -327
  58. package/tsconfig.json +0 -10
@@ -1,984 +0,0 @@
1
- import type Storage from '../../../storage.js';
2
- import type { InstallerOptions } from '../../codegenerator.js';
3
- import type Oas from 'oas';
4
- import type Operation from 'oas/operation';
5
- import type { HttpMethods, SchemaObject } from 'oas/rmoas.types';
6
- import type { SemVer } from 'semver';
7
- import type {
8
- ClassDeclaration,
9
- Directory,
10
- JSDocStructure,
11
- JSDocTagStructure,
12
- OptionalKind,
13
- ParameterDeclarationStructure,
14
- } from 'ts-morph';
15
- import type { Options } from 'tsup';
16
- import type { JsonObject, PackageJson, TsConfigJson } from 'type-fest';
17
-
18
- import path from 'node:path';
19
-
20
- import corePkg from '@readme/api-core/package.json' assert { type: 'json' };
21
- import execa from 'execa';
22
- import setWith from 'lodash.setwith';
23
- import semver from 'semver';
24
- import { IndentationText, Project, QuoteKind, ScriptTarget, VariableDeclarationKind } from 'ts-morph';
25
-
26
- import logger from '../../../logger.js';
27
- import CodeGenerator from '../../codegenerator.js';
28
-
29
- import { docblockEscape, generateTypeName, wordWrap } from './util.js';
30
-
31
- interface OperationTypeHousing {
32
- operation: Operation;
33
- types: {
34
- params?: false | Record<'body' | 'formData' | 'metadata', string>;
35
- responses?: Record<
36
- string | number,
37
- {
38
- description?: string;
39
- type: string;
40
- }
41
- >;
42
- };
43
- }
44
-
45
- /**
46
- * This is the conversion prefix that we add to all `$ref` pointers we find in generated JSON
47
- * Schema.
48
- *
49
- * Because the pointer name is a string we want to have it reference the schema constant we're
50
- * adding into the codegen'd schema file. As there's no way, not even using `eval()` in this case,
51
- * to convert a string to a constant we're prefixing them with this so we can later remove it and
52
- * rewrite the value to a literal. eg. `'Pet'` becomes `Pet`.
53
- *
54
- * And because our TypeScript type name generator properly ignores `:`, this is safe to prepend to
55
- * all generated type names.
56
- */
57
- const REF_PLACEHOLDER = '::convert::';
58
- const REF_PLACEHOLDER_REGEX = /"::convert::([a-zA-Z_$\\d]*)"/g;
59
-
60
- export default class TSGenerator extends CodeGenerator {
61
- project: Project;
62
-
63
- types: Map<string, string>;
64
-
65
- sdk!: ClassDeclaration;
66
-
67
- schemas: Record<
68
- string,
69
- // Operation-level type
70
- | {
71
- body?: unknown;
72
- metadata?: unknown;
73
- response?: Record<string, unknown>;
74
- }
75
- // Wholesale collection of `$ref` pointer types
76
- | Record<string, unknown>
77
- >;
78
-
79
- usesHTTPMethodRangeInterface = false;
80
-
81
- constructor(spec: Oas, specPath: string, identifier: string) {
82
- super(spec, specPath, identifier);
83
-
84
- this.requiredPackages = {
85
- '@readme/api-core': {
86
- dependencyType: 'production',
87
- reason: "The core magic of your codegen'd SDK and is what is used for making requests.",
88
- url: 'https://npm.im/api',
89
- version:
90
- // When running unit tests we're installing `@readme/api-core` but because that package
91
- // source lives in this repository NPM will throw a gnarly "Cannot set properties of null
92
- // (setting 'dev')" workspace error message because we're creating a funky circular
93
- // dependency.
94
- process.env.NODE_ENV === 'test'
95
- ? `file:${path.relative(__dirname, path.dirname(require.resolve('@readme/api-core/package.json')))}`
96
- : corePkg.version,
97
- },
98
- 'json-schema-to-ts': {
99
- dependencyType: 'production',
100
- reason: 'Required for TypeScript type handling.',
101
- url: 'https://npm.im/json-schema-to-ts',
102
- version: '^2.9.2',
103
- },
104
- tsup: {
105
- dependencyType: 'development',
106
- reason: "Used for compiling your codegen'd SDK into code that can be used in JS environments.",
107
- url: 'https://tsup.egoist.dev/',
108
- version: '^7.2.0',
109
- },
110
- typescript: {
111
- dependencyType: 'development',
112
- reason: 'Required for `tsup`.',
113
- version: '^5.2.2',
114
- },
115
- };
116
-
117
- this.project = new Project({
118
- compilerOptions: {
119
- outDir: 'dist',
120
- resolveJsonModule: true,
121
- target: ScriptTarget.ES2022,
122
- },
123
- manipulationSettings: {
124
- indentationText: IndentationText.TwoSpaces,
125
- quoteKind: QuoteKind.Single,
126
- },
127
- useInMemoryFileSystem: true,
128
- });
129
-
130
- this.types = new Map();
131
- this.schemas = {};
132
- }
133
-
134
- // eslint-disable-next-line class-methods-use-this
135
- async install(storage: Storage, opts: InstallerOptions = {}): Promise<void> {
136
- const installDir = storage.getIdentifierStorageDir();
137
-
138
- const npmInstall = ['install', '--save', opts.dryRun ? '--dry-run' : ''].filter(Boolean);
139
-
140
- // This will install the installed SDK as a dependency within the current working directory,
141
- // adding `@api/<sdk identifier>` as a dependency there so you can load it with
142
- // `require('@api/<sdk identifier>)`.
143
- await execa('npm', [...npmInstall, installDir].filter(Boolean))
144
- .then(res => {
145
- if (opts.dryRun) {
146
- (opts.logger ? opts.logger : logger)(res.command);
147
- (opts.logger ? opts.logger : logger)(res.stdout);
148
- }
149
- })
150
- .catch(err => {
151
- // If `npm install` throws this error it always happens **after** our dependencies have been
152
- // installed and is an annoying quirk that sometimes occurs when installing a package within
153
- // our workspace as we're creating a circular dependency on `@readme/api-core`.
154
- if (
155
- process.env.NODE_ENV === 'test' &&
156
- err.message.includes("npm ERR! Cannot set properties of null (setting 'dev')")
157
- ) {
158
- (opts.logger ? opts.logger : logger)("npm threw an error but we're ignoring it");
159
- return;
160
- }
161
-
162
- if (opts.dryRun) {
163
- (opts.logger ? opts.logger : logger)(err.message);
164
- return;
165
- }
166
-
167
- throw err;
168
- });
169
- }
170
-
171
- /**
172
- * Compile the TS code we generated into JS for use in CJS and ESM environments.
173
- *
174
- */
175
- // eslint-disable-next-line class-methods-use-this
176
- async compile(storage: Storage, opts: InstallerOptions = {}): Promise<void> {
177
- const installDir = storage.getIdentifierStorageDir();
178
-
179
- await execa('npx', ['tsup'], {
180
- cwd: installDir,
181
- })
182
- .then(res => {
183
- if (opts.dryRun) {
184
- (opts.logger ? opts.logger : logger)(res.command);
185
- (opts.logger ? opts.logger : logger)(res.stdout);
186
- }
187
- })
188
- .catch(err => {
189
- if (opts.dryRun) {
190
- (opts.logger ? opts.logger : logger)(err.message);
191
- return;
192
- }
193
-
194
- throw err;
195
- });
196
- }
197
-
198
- /**
199
- * Generate the current OpenAPI definition into a TypeScript library.
200
- *
201
- */
202
- async generate() {
203
- const srcDirectory = this.project.createDirectory('src');
204
- const sdkSource = this.createSDKSource(srcDirectory);
205
-
206
- this.createPackageJSON();
207
- this.createTSConfig();
208
-
209
- if (Object.keys(this.schemas).length) {
210
- this.createSchemasFile(srcDirectory);
211
- this.createTypesFile(srcDirectory);
212
- } else {
213
- // If we don't have any schemas then we shouldn't import a `types` file that doesn't exist.
214
- sdkSource
215
- .getImportDeclarations()
216
- .find(id => id.getText() === "import type * as types from './types';")
217
- ?.remove();
218
- }
219
-
220
- // If this SDK doesn't use the `HTTPMethodRange` interface for handling `2XX` response status
221
- // codes then we should remove it from being imported.
222
- if (!this.usesHTTPMethodRangeInterface) {
223
- sdkSource
224
- .getImportDeclarations()
225
- .find(id => id.getText().includes('HTTPMethodRange'))
226
- ?.replaceWithText("import type { ConfigOptions, FetchResponse } from '@readme/api-core';");
227
- }
228
-
229
- return [
230
- ...this.project.getSourceFiles().map(sourceFile => {
231
- // `getFilePath` will always return a string that contains a preceeding directory separator
232
- // however when we're creating these codegen'd files that may cause us to create that file
233
- // in the root directory (because it's preceeded by a `/`). We don't want that to happen so
234
- // we're slicing off that first character.
235
- let filePath = sourceFile.getFilePath().toString();
236
- filePath = filePath.substring(1);
237
-
238
- return {
239
- [filePath]: sourceFile.getFullText(),
240
- };
241
- }),
242
-
243
- // Because we're returning the raw source files for TS generation we also need to separately
244
- // emit out our declaration files so we can put those into a separate file in the installed
245
- // SDK directory.
246
- ...this.project
247
- .emitToMemory({ emitOnlyDtsFiles: true })
248
- .getFiles()
249
- .map(sourceFile => ({
250
- [path.basename(sourceFile.filePath)]: sourceFile.text,
251
- })),
252
- ].reduce((prev, next) => Object.assign(prev, next));
253
- }
254
-
255
- /**
256
- * Create our main SDK source file.
257
- *
258
- */
259
- private createSDKSource(sourceDirectory: Directory) {
260
- const { operations } = this.loadOperationsAndMethods();
261
-
262
- const sourceFile = sourceDirectory.createSourceFile('index.ts', '');
263
-
264
- sourceFile.addImportDeclarations([
265
- // This import will be automatically removed later if the SDK ends up not having any types.
266
- { defaultImport: 'type * as types', moduleSpecifier: './types' },
267
- {
268
- // `HTTPMethodRange` will be conditionally removed later if it ends up not being used.
269
- defaultImport: 'type { ConfigOptions, FetchResponse, HTTPMethodRange }',
270
- moduleSpecifier: '@readme/api-core',
271
- },
272
- { defaultImport: 'APICore', moduleSpecifier: '@readme/api-core' },
273
- { defaultImport: 'definition', moduleSpecifier: this.specPath },
274
- ]);
275
-
276
- // @todo add TOS, License, info.* to a docblock at the top of the SDK.
277
- this.sdk = sourceFile.addClass({
278
- name: 'SDK',
279
- properties: [{ name: 'core', type: 'APICore' }],
280
- });
281
-
282
- this.sdk.addConstructor({
283
- statements: writer => {
284
- writer.write('this.core = new APICore(definition, ').quote(this.userAgent).write(');');
285
- return writer;
286
- },
287
- });
288
-
289
- // Add our core API methods for controlling auth, servers, and various configurable abilities.
290
- this.sdk.addMethods([
291
- {
292
- name: 'config',
293
- parameters: [{ name: 'config', type: 'ConfigOptions' }],
294
- statements: writer => writer.writeLine('this.core.setConfig(config);'),
295
- docs: [
296
- {
297
- description: writer =>
298
- writer.writeLine(wordWrap('Optionally configure various options that the SDK allows.')),
299
- tags: [
300
- { tagName: 'param', text: 'config Object of supported SDK options and toggles.' },
301
- {
302
- tagName: 'param',
303
- text: wordWrap(
304
- 'config.timeout Override the default `fetch` request timeout of 30 seconds. This number should be represented in milliseconds.',
305
- ),
306
- },
307
- ],
308
- },
309
- ],
310
- },
311
- {
312
- name: 'auth',
313
- parameters: [{ name: '...values', type: 'string[] | number[]' }],
314
- statements: writer => {
315
- writer.writeLine('this.core.setAuth(...values);');
316
- writer.writeLine('return this;');
317
- return writer;
318
- },
319
- docs: [
320
- {
321
- description: writer =>
322
- writer.writeLine(
323
- 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.
324
-
325
- With the exception of OpenID and MutualTLS, it supports all forms of authentication supported by the OpenAPI specification.
326
-
327
- @example <caption>HTTP Basic auth</caption>
328
- sdk.auth('username', 'password');
329
-
330
- @example <caption>Bearer tokens (HTTP or OAuth 2)</caption>
331
- sdk.auth('myBearerToken');
332
-
333
- @example <caption>API Keys</caption>
334
- sdk.auth('myApiKey');`),
335
- ),
336
- tags: [
337
- { tagName: 'see', text: '{@link https://spec.openapis.org/oas/v3.0.3#fixed-fields-22}' },
338
- { tagName: 'see', text: '{@link https://spec.openapis.org/oas/v3.1.0#fixed-fields-22}' },
339
- {
340
- tagName: 'param',
341
- text: 'values Your auth credentials for the API; can specify up to two strings or numbers.',
342
- },
343
- ],
344
- },
345
- ],
346
- },
347
- {
348
- name: 'server',
349
- parameters: [
350
- { name: 'url', type: 'string' },
351
- { name: 'variables', initializer: '{}' },
352
- ],
353
- statements: writer => writer.writeLine('this.core.setServer(url, variables);'),
354
- docs: [
355
- {
356
- description: writer =>
357
- writer.writeLine(
358
- 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).
359
-
360
- @example <caption>Server URL with server variables</caption>
361
- sdk.server('https://{region}.api.example.com/{basePath}', {
362
- name: 'eu',
363
- basePath: 'v14',
364
- });
365
-
366
- @example <caption>Fully qualified server URL</caption>
367
- sdk.server('https://eu.api.example.com/v14');`),
368
- ),
369
- tags: [
370
- { tagName: 'param', text: 'url Server URL' },
371
- { tagName: 'param', text: 'variables An object of variables to replace into the server URL.' },
372
- ],
373
- },
374
- ],
375
- },
376
- ]);
377
-
378
- // Add all available operation ID accessors into the SDK.
379
- Object.entries(operations).forEach(([operationId, data]: [string, OperationTypeHousing]) => {
380
- this.createOperationAccessor(data.operation, operationId, data.types.params, data.types.responses);
381
- });
382
-
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 IIFE.
391
- writer.writeLine('(() => { return new SDK(); })()');
392
- return writer;
393
- },
394
- },
395
- ],
396
- });
397
-
398
- sourceFile.addExportAssignment({
399
- // Because we're exporting `createSDK` as an IIFE constant we need to have it exported as
400
- // `export default createSDK`. `addExportAssignment` by default wants it exported as
401
- // `export = createSDK`, which will throw TS errors because we may also be exporting types in
402
- // the `./types.ts` file.
403
- isExportEquals: false,
404
- expression: 'createSDK',
405
- });
406
-
407
- return sourceFile;
408
- }
409
-
410
- /**
411
- * Create the `tsconfig.json` file that will allow this SDK to be compiled for use.
412
- *
413
- */
414
- createTSConfig() {
415
- const sourceFile = this.project.createSourceFile('tsconfig.json', '');
416
-
417
- const config: TsConfigJson = {
418
- compilerOptions: {
419
- module: 'NodeNext',
420
- resolveJsonModule: true,
421
- },
422
- include: ['./src/**/*'],
423
- };
424
-
425
- sourceFile.addStatements(JSON.stringify(config, null, 2));
426
-
427
- return sourceFile;
428
- }
429
-
430
- /**
431
- * Create the `package.json` file that will ultimately make this SDK available to use.
432
- *
433
- */
434
- createPackageJSON() {
435
- const sourceFile = this.project.createSourceFile('package.json', '');
436
-
437
- const hasTypes = !!Object.keys(this.schemas).length;
438
-
439
- const info = this.spec.getDefinition().info;
440
- let pkgVersion = semver.coerce(info.version);
441
- if (!pkgVersion) {
442
- // If the version that's in `info.version` isn't compatible with semver NPM won't be able to
443
- // handle it properly so we need to fallback to something it can.
444
- pkgVersion = semver.coerce('0.0.0') as SemVer;
445
- }
446
-
447
- const tsupOptions: Options = {
448
- cjsInterop: true,
449
- clean: true,
450
- dts: true,
451
- entry: [
452
- './src/index.ts',
453
- // If this SDK has schemas and generated types then we should also export those too so
454
- // they're available to use.
455
- hasTypes ? './src/types.ts' : '',
456
- ].filter(Boolean),
457
- format: ['esm', 'cjs'],
458
- minify: false,
459
- shims: true,
460
- sourcemap: true,
461
- splitting: true,
462
- };
463
-
464
- const dependencies = Object.entries(this.requiredPackages)
465
- .map(([dep, { dependencyType, version }]) => (dependencyType === 'production' ? { [dep]: version } : {}))
466
- .reduce((prev, next) => Object.assign(prev, next));
467
-
468
- const devDependencies = Object.entries(this.requiredPackages)
469
- .map(([dep, { dependencyType, version }]) => (dependencyType === 'development' ? { [dep]: version } : {}))
470
- .reduce((prev, next) => Object.assign(prev, next));
471
-
472
- const pkg: PackageJson = {
473
- name: `@api/${this.identifier}`,
474
- version: pkgVersion.version,
475
- main: './dist/index.js',
476
- types: './dist/index.d.ts',
477
- module: './dist/index.mts',
478
- exports: {
479
- '.': {
480
- import: './dist/index.mjs',
481
- require: './dist/index.js',
482
- },
483
- ...(hasTypes
484
- ? {
485
- './types': {
486
- import: './dist/types.d.mts',
487
- require: './dist/types.d.ts',
488
- },
489
- }
490
- : {}),
491
- },
492
- files: ['dist'],
493
- scripts: {
494
- prepare: 'tsup',
495
- },
496
- dependencies,
497
- devDependencies,
498
- tsup: tsupOptions as JsonObject,
499
- };
500
-
501
- sourceFile.addStatements(JSON.stringify(pkg, null, 2));
502
-
503
- return sourceFile;
504
- }
505
-
506
- /**
507
- * Create our main schemas file. This is where all of the JSON Schema that our TypeScript typing
508
- * infrastructure sources its data from. Without this there are no types.
509
- *
510
- */
511
- private createSchemasFile(sourceDirectory: Directory) {
512
- const sourceFile = sourceDirectory.createSourceFile('schemas.ts', '');
513
- const schemasDir = sourceDirectory.createDirectory('schemas');
514
-
515
- const sortedSchemas = new Map(Array.from(Object.entries(this.schemas)).sort());
516
-
517
- Array.from(sortedSchemas).forEach(([schemaName, schema]) => {
518
- const schemaFile = schemasDir.createSourceFile(`${schemaName}.ts`);
519
-
520
- // Because we're chunking our schemas into a `schemas/` directory we need to add imports
521
- // for these schemas into our main `schemas.ts` file.`
522
- sourceFile.addImportDeclaration({
523
- defaultImport: schemaName,
524
- moduleSpecifier: `./schemas/${schemaName}`,
525
- });
526
-
527
- let str = JSON.stringify(schema);
528
- const referencedSchemas = str.match(REF_PLACEHOLDER_REGEX)?.map(s => s.replace(REF_PLACEHOLDER_REGEX, '$1'));
529
- if (referencedSchemas) {
530
- referencedSchemas.sort();
531
- referencedSchemas.forEach(ref => {
532
- // Because this schema is referenced from another file we need to create an `import`
533
- // declaration for it.
534
- schemaFile.addImportDeclaration({
535
- defaultImport: ref,
536
- moduleSpecifier: `./${ref}`,
537
- });
538
- });
539
- }
540
-
541
- // Load the schema into the schema file within the `schemas/` directory.
542
- schemaFile.addVariableStatement({
543
- declarationKind: VariableDeclarationKind.Const,
544
- declarations: [
545
- {
546
- name: schemaName,
547
- initializer: writer => {
548
- // We can't have `::convert::<schemaName>` variables within these schema files so we
549
- // need to clean them up.
550
- str = str.replace(REF_PLACEHOLDER_REGEX, '$1');
551
-
552
- writer.writeLine(`${str} as const`);
553
- return writer;
554
- },
555
- },
556
- ],
557
- });
558
-
559
- schemaFile.addStatements(`export default ${schemaName}`);
560
- });
561
-
562
- // Export all of our schemas from inside the main `schemas.ts` file.
563
- sourceFile.addStatements(`export { ${Array.from(sortedSchemas.keys()).join(', ')} }`);
564
-
565
- return sourceFile;
566
- }
567
-
568
- /**
569
- * Create our main types file. This sources its data from the JSON Schema `schemas.ts` file and
570
- * will re-export types to be used in TypeScript implementations and IDE intellisense. This
571
- * typing work is functional with the `json-schema-to-ts` library.
572
- *
573
- * @see {@link https://npm.im/json-schema-to-ts}
574
- */
575
- private createTypesFile(sourceDirectory: Directory) {
576
- const sourceFile = sourceDirectory.createSourceFile('types.ts', '');
577
-
578
- sourceFile.addImportDeclarations([
579
- { defaultImport: 'type { FromSchema }', moduleSpecifier: 'json-schema-to-ts' },
580
- { defaultImport: '* as schemas', moduleSpecifier: './schemas' },
581
- ]);
582
-
583
- Array.from(new Map(Array.from(this.types.entries()).sort())).forEach(([typeName, typeExpression]) => {
584
- sourceFile.addTypeAlias({ isExported: true, name: typeName, type: typeExpression });
585
- });
586
-
587
- return sourceFile;
588
- }
589
-
590
- /**
591
- * Create operation accessors on the SDK.
592
- *
593
- */
594
- private createOperationAccessor(
595
- operation: Operation,
596
- operationId: string,
597
- paramTypes?: OperationTypeHousing['types']['params'],
598
- responseTypes?: OperationTypeHousing['types']['responses'],
599
- ) {
600
- let docblock: OptionalKind<JSDocStructure> = {};
601
- const summary = operation.getSummary();
602
- const description = operation.getDescription();
603
- if (summary || description) {
604
- // To keep our generated docblocks clean we should only add the `@summary` tag if we've
605
- // got both a summary and a description present on the operation, otherwise we can alternate
606
- // what we surface the main docblock description.
607
- docblock.description = writer => {
608
- if (description) {
609
- writer.writeLine(docblockEscape(wordWrap(description)));
610
- } else if (summary) {
611
- writer.writeLine(docblockEscape(wordWrap(summary)));
612
- }
613
-
614
- writer.newLineIfLastNot();
615
- return writer;
616
- };
617
-
618
- if (summary && description) {
619
- docblock = TSGenerator.#addTagToDocblock(docblock, {
620
- tagName: 'summary',
621
- text: docblockEscape(wordWrap(summary)),
622
- });
623
- }
624
- }
625
-
626
- let hasOptionalBody = false;
627
- let hasOptionalMetadata = false;
628
- const parameters = {} as {
629
- body: OptionalKind<ParameterDeclarationStructure>;
630
- metadata: OptionalKind<ParameterDeclarationStructure>;
631
- };
632
-
633
- if (paramTypes) {
634
- // If an operation has a request body payload it will only ever have `body` or `formData`,
635
- // never both, as these are determined upon the media type that's in use.
636
- if (paramTypes.body || paramTypes.formData) {
637
- hasOptionalBody = !operation.hasRequiredRequestBody();
638
-
639
- parameters.body = {
640
- name: 'body',
641
- type: paramTypes.body ? paramTypes.body : paramTypes.formData,
642
- hasQuestionToken: hasOptionalBody,
643
- };
644
- }
645
-
646
- if (paramTypes.metadata) {
647
- hasOptionalMetadata = !operation.hasRequiredParameters();
648
-
649
- parameters.metadata = {
650
- name: 'metadata',
651
- type: paramTypes.metadata,
652
- hasQuestionToken: hasOptionalMetadata,
653
- };
654
- }
655
- }
656
-
657
- let returnType = 'Promise<FetchResponse<number, unknown>>';
658
- if (responseTypes) {
659
- const returnTypes = Object.entries(responseTypes)
660
- .map(([status, { description: responseDescription, type: responseType }]) => {
661
- if (status.toLowerCase() === 'default') {
662
- return `FetchResponse<number, ${responseType}>`;
663
- } else if (status.length === 3 && status.toUpperCase().endsWith('XX')) {
664
- const statusPrefix = status.slice(0, 1);
665
- if (!Number.isInteger(Number(statusPrefix))) {
666
- // If this matches the `_XX` format, but it isn't `{number}XX` then we can't handle
667
- // it and should instead fall back to treating it as an unknown number.
668
- return `FetchResponse<number, ${responseType}>`;
669
- }
670
-
671
- if (Number(statusPrefix) >= 4) {
672
- docblock = TSGenerator.#addTagToDocblock(docblock, {
673
- tagName: 'throws',
674
- text: `FetchError<${status}, ${responseType}>${
675
- responseDescription ? docblockEscape(wordWrap(` ${responseDescription}`)) : ''
676
- }`,
677
- });
678
-
679
- return false;
680
- }
681
-
682
- this.usesHTTPMethodRangeInterface = true;
683
- return `FetchResponse<HTTPMethodRange<${statusPrefix}00, ${statusPrefix}99>, ${responseType}>`;
684
- }
685
-
686
- // 400 and 500 status code families are thrown as exceptions so adding them as a possible
687
- // return type isn't valid.
688
- if (Number(status) >= 400) {
689
- docblock = TSGenerator.#addTagToDocblock(docblock, {
690
- tagName: 'throws',
691
- text: `FetchError<${status}, ${responseType}>${
692
- responseDescription ? docblockEscape(wordWrap(` ${responseDescription}`)) : ''
693
- }`,
694
- });
695
-
696
- return false;
697
- }
698
-
699
- return `FetchResponse<${status}, ${responseType}>`;
700
- })
701
- .filter(Boolean)
702
- .join(' | ');
703
-
704
- // If all of our documented responses are for error status codes then all we can document for
705
- // anything else that might happen is `unknown`.
706
- returnType = `Promise<${returnTypes.length ? returnTypes : 'FetchResponse<number, unknown>'}>`;
707
- }
708
-
709
- const shouldAddAltTypedOverloads = Object.keys(parameters).length === 2 && hasOptionalBody && !hasOptionalMetadata;
710
- const operationIdAccessor = this.sdk.addMethod({
711
- name: operationId,
712
- returnType,
713
-
714
- // If we're going to be creating typed method overloads for optional body an metadata handling
715
- // we should only add a docblock to the first overload we create because IDE Intellisense will
716
- // always use that and adding a docblock to all three will bloat the SDK with unused and
717
- // unsurfaced method documentation.
718
- docs: shouldAddAltTypedOverloads ? undefined : Object.keys(docblock).length ? [docblock] : undefined,
719
- statements: writer => {
720
- /**
721
- * @example return this.core.fetch('/pet/findByStatus', 'get', body, metadata);
722
- * @example return this.core.fetch('/pet/findByStatus', 'get', metadata);
723
- */
724
- const fetchStmt = writer
725
- .write('return this.core.fetch(')
726
- .quote(operation.path)
727
- .write(', ')
728
- .quote(operation.method);
729
-
730
- const totalParams = Object.keys(parameters).length;
731
- if (totalParams) {
732
- Object.values(parameters).forEach((arg, i) => {
733
- if (i === 0) {
734
- fetchStmt.write(', ');
735
- }
736
-
737
- fetchStmt.write(arg.name);
738
- if (i !== totalParams - 1) {
739
- fetchStmt.write(', ');
740
- }
741
- });
742
- }
743
-
744
- fetchStmt.write(');');
745
- return fetchStmt;
746
- },
747
- });
748
-
749
- // If we have both body and metadata parameters but only body is optional we need to create
750
- // a couple function overloads as Typescript doesn't let us have an optional method parameter
751
- // come before one that's required.
752
- if (shouldAddAltTypedOverloads) {
753
- // Create an overload that has both `body` and `metadata` parameters as required.
754
- operationIdAccessor.addOverload({
755
- parameters: [
756
- { ...parameters.body, hasQuestionToken: false },
757
- { ...parameters.metadata, hasQuestionToken: false },
758
- ],
759
- returnType,
760
- docs: Object.keys(docblock).length ? [docblock] : undefined,
761
- });
762
-
763
- // Create an overload that just has a single `metadata` parameter.
764
- operationIdAccessor.addOverload({
765
- parameters: [{ ...parameters.metadata }],
766
- returnType,
767
- });
768
-
769
- // Create an overload that has both `body` and `metadata` parameters as optional. Even though
770
- // our `metadata` parameter is actually required for this operation this is the only way we're
771
- // able to have an optional `body` parameter be present before `metadata`.
772
- //
773
- // Thankfully our core fetch work in `@readme/api-core` is able to do the proper determination to
774
- // see if what the user is supplying is `metadata` or `body` content when they supply one or
775
- // both.
776
- operationIdAccessor.addParameters([
777
- {
778
- ...parameters.body,
779
- // Overloads have to be the most distilled version of the method so that's why we need to
780
- // type `body` as either `body` or `metadata`. If we didn't do this, if `body` was a JSON
781
- // Schema type that didn't allow `additionalProperties` then the implementation overload
782
- // would throw type errors.
783
- type: `${parameters.body.type} | ${parameters.metadata.type}`,
784
- hasQuestionToken: true,
785
- },
786
- { ...parameters.metadata, hasQuestionToken: true },
787
- ]);
788
- } else {
789
- operationIdAccessor.addParameters(Object.values(parameters));
790
- }
791
- }
792
-
793
- /**
794
- * Scour through the current OpenAPI definition and compile a store of every operation, along
795
- * with every HTTP method that's in use, and their available TypeScript types that we can use,
796
- * along with every HTTP method that's in use.
797
- *
798
- */
799
- private loadOperationsAndMethods() {
800
- const operations: Record</* operationId */ string, OperationTypeHousing> = {};
801
- const methods = new Set<HttpMethods>();
802
-
803
- // Prepare all of the schemas that we need to process for every operation within this API
804
- // definition.
805
- Object.entries(this.spec.getPaths()).forEach(([, ops]) => {
806
- Object.entries(ops).forEach(([method, operation]: [string, Operation]) => {
807
- methods.add(method as HttpMethods);
808
-
809
- const operationId = operation.getOperationId({
810
- // This `camelCase` option will clean up any weird characters that might be present in
811
- // the `operationId` so as we don't break TS compilation with an invalid method accessor.
812
- camelCase: true,
813
- });
814
-
815
- operations[operationId] = {
816
- types: {
817
- params: this.prepareParameterTypesForOperation(operation, operationId),
818
- responses: this.prepareResponseTypesForOperation(operation, operationId),
819
- },
820
- operation,
821
- };
822
- });
823
- });
824
-
825
- if (!Object.keys(operations).length) {
826
- throw new Error('Sorry, this OpenAPI definition does not have any operation paths to generate an SDK for.');
827
- }
828
-
829
- return {
830
- operations,
831
- methods,
832
- };
833
- }
834
-
835
- /**
836
- * Compile the parameter (path, query, cookie, and header) schemas for an API operation into
837
- * usable TypeScript types.
838
- *
839
- */
840
- private prepareParameterTypesForOperation(operation: Operation, operationId: string) {
841
- const schemas = operation.getParametersAsJSONSchema({
842
- includeDiscriminatorMappingRefs: false,
843
- mergeIntoBodyAndMetadata: true,
844
- retainDeprecatedProperties: true,
845
- transformer: (s: SchemaObject) => {
846
- // As our schemas are dereferenced in the `oas` library we don't want to pollute our
847
- // codegen'd schemas file with duplicate schemas.
848
- if ('x-readme-ref-name' in s && typeof s['x-readme-ref-name'] !== 'undefined') {
849
- const typeName = generateTypeName(s['x-readme-ref-name']);
850
- this.addSchemaToExport(s, typeName, typeName);
851
-
852
- return `${REF_PLACEHOLDER}${typeName}` as SchemaObject;
853
- }
854
-
855
- return s;
856
- },
857
- });
858
-
859
- if (!schemas || !schemas.length) {
860
- return false;
861
- }
862
-
863
- const res = schemas
864
- .map(param => ({ [param.type]: param.schema }))
865
- .reduce((prev, next) => Object.assign(prev, next));
866
-
867
- return Object.entries(res)
868
- .map(([paramType, schema]: [string, string | SchemaObject]) => {
869
- let typeName;
870
-
871
- if (typeof schema === 'string' && schema.startsWith(REF_PLACEHOLDER)) {
872
- // If this schema is a string and has our conversion prefix then we've already created
873
- // a type for it.
874
- typeName = schema.replace(REF_PLACEHOLDER, '');
875
- } else {
876
- typeName = generateTypeName(operationId, paramType, 'param');
877
- this.addSchemaToExport(schema as SchemaObject, typeName, `${generateTypeName(operationId)}.${paramType}`);
878
- }
879
-
880
- return {
881
- // Types are prefixed with `types.` because that's how we're importing them from
882
- // `types.d.ts`.
883
- [paramType]: `types.${typeName}`,
884
- };
885
- })
886
- .reduce((prev, next) => Object.assign(prev, next), {}) as Record<'body' | 'formData' | 'metadata', string>;
887
- }
888
-
889
- /**
890
- * Compile the response schemas for an API operation into usable TypeScript types.
891
- *
892
- */
893
- private prepareResponseTypesForOperation(operation: Operation, operationId: string) {
894
- const responseStatusCodes = operation.getResponseStatusCodes();
895
- if (!responseStatusCodes.length) {
896
- return undefined;
897
- }
898
-
899
- const schemas = responseStatusCodes
900
- .map(status => {
901
- const schema = operation.getResponseAsJSONSchema(status, {
902
- includeDiscriminatorMappingRefs: false,
903
- transformer: (s: SchemaObject) => {
904
- // As our schemas are dereferenced in the `oas` library we don't want to pollute our
905
- // codegen'd schemas file with duplicate schemas.
906
- if ('x-readme-ref-name' in s && typeof s['x-readme-ref-name'] !== 'undefined') {
907
- const typeName = generateTypeName(s['x-readme-ref-name']);
908
- this.addSchemaToExport(s, typeName, `${typeName}`);
909
-
910
- return `${REF_PLACEHOLDER}${typeName}` as SchemaObject;
911
- }
912
-
913
- return s;
914
- },
915
- });
916
-
917
- if (!schema) {
918
- return false;
919
- }
920
-
921
- return {
922
- [status]: schema.shift(),
923
- };
924
- })
925
- .reduce((prev, next) => Object.assign(prev, next));
926
-
927
- const res = Object.entries(schemas)
928
- .map(([status, { description, schema }]) => {
929
- let typeName;
930
-
931
- if (typeof schema === 'string' && schema.startsWith(REF_PLACEHOLDER)) {
932
- // If this schema is a string and has our conversion prefix then we've already created
933
- // a type for it.
934
- typeName = schema.replace(REF_PLACEHOLDER, '');
935
- } else {
936
- typeName = generateTypeName(operationId, 'response', status);
937
-
938
- // Because `status` will usually be a number here we need to set the pointer for it
939
- // within an `[]` as if we do `FromSchema<typeof schemas.operation.response.200>`,
940
- // TypeScript will throw a compilation error.
941
- this.addSchemaToExport(schema, typeName, `${generateTypeName(operationId)}.response['${status}']`);
942
- }
943
-
944
- return {
945
- // Types are prefixed with `types.` because that's how we're importing them from
946
- // `types.d.ts`.
947
- [status]: {
948
- type: `types.${typeName}`,
949
- description,
950
- },
951
- };
952
- })
953
- .reduce((prev, next) => Object.assign(prev, next), {});
954
-
955
- return Object.keys(res).length ? res : undefined;
956
- }
957
-
958
- /**
959
- * Add a given schema into our schema dataset that we'll be be exporting as types.
960
- *
961
- */
962
- private addSchemaToExport(schema: SchemaObject, typeName: string, pointer: string) {
963
- if (this.types.has(typeName)) {
964
- return;
965
- }
966
-
967
- setWith(this.schemas, pointer, schema, Object);
968
- this.types.set(typeName, `FromSchema<typeof schemas.${pointer}>`);
969
- }
970
-
971
- /**
972
- * Add a new JSDoc `@tag` to an existing docblock.
973
- *
974
- */
975
- static #addTagToDocblock(docblock: OptionalKind<JSDocStructure>, tag: OptionalKind<JSDocTagStructure>) {
976
- const tags = docblock.tags ?? [];
977
- tags.push(tag);
978
-
979
- return {
980
- ...docblock,
981
- tags,
982
- };
983
- }
984
- }