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