1 |
|
2 |
|
3 | import doctrine from 'doctrine'
|
4 | import fs from 'fs-extra'
|
5 | import { version } from 'fts-core'
|
6 | import path from 'path'
|
7 | import tempy from 'tempy'
|
8 | import * as TS from 'ts-morph'
|
9 | import * as TJS from 'typescript-json-schema'
|
10 | import * as FTS from './types'
|
11 |
|
12 | const FTSReturns = 'FTSReturns'
|
13 | const FTSParams = 'FTSParams'
|
14 |
|
15 | const promiseTypeRe = /^Promise<(.*)>$/
|
16 |
|
17 | const supportedExtensions = {
|
18 | js: 'javascript',
|
19 | jsx: 'javascript',
|
20 | ts: 'typescript',
|
21 | tsx: 'typescript'
|
22 | }
|
23 |
|
24 | export async function generateDefinition(
|
25 | file: string,
|
26 | options: FTS.PartialDefinitionOptions = {}
|
27 | ): Promise<FTS.Definition> {
|
28 | file = path.resolve(file)
|
29 | const fileInfo = path.parse(file)
|
30 | const language = supportedExtensions[fileInfo.ext.substr(1)]
|
31 |
|
32 | if (!language) {
|
33 | throw new Error(`File type "${fileInfo.ext}" not supported. "${file}"`)
|
34 | }
|
35 |
|
36 | const outDir = tempy.directory()
|
37 |
|
38 |
|
39 | const compilerOptions = {
|
40 | allowJs: true,
|
41 | ignoreCompilerErrors: true,
|
42 |
|
43 | lib: ['lib.es2018.d.ts', 'lib.dom.d.ts'],
|
44 | target: TS.ScriptTarget.ES5,
|
45 | outDir,
|
46 | ...(options.compilerOptions || {})
|
47 | }
|
48 |
|
49 | const jsonSchemaOptions = {
|
50 | noExtraProps: true,
|
51 | required: true,
|
52 | validationKeywords: ['coerceTo', 'coerceFrom'],
|
53 | ...(options.jsonSchemaOptions || {})
|
54 | }
|
55 |
|
56 | const definition: Partial<FTS.Definition> = {
|
57 | config: {
|
58 | defaultExport: true,
|
59 | language
|
60 | },
|
61 | params: {
|
62 | context: false,
|
63 | order: [],
|
64 | schema: null
|
65 | },
|
66 | returns: {
|
67 | async: false,
|
68 | http: false,
|
69 | schema: null
|
70 | },
|
71 | version
|
72 | }
|
73 |
|
74 | const project = new TS.Project({ compilerOptions })
|
75 |
|
76 | project.addExistingSourceFile(file)
|
77 | project.resolveSourceFileDependencies()
|
78 |
|
79 | const diagnostics = project.getPreEmitDiagnostics()
|
80 | if (diagnostics.length > 0) {
|
81 | console.log(project.formatDiagnosticsWithColorAndContext(diagnostics))
|
82 |
|
83 |
|
84 | }
|
85 |
|
86 | const sourceFile = project.getSourceFileOrThrow(file)
|
87 | const main = extractMainFunction(sourceFile, definition)
|
88 |
|
89 | if (!main) {
|
90 | throw new Error(`Unable to infer a main function export "${file}"`)
|
91 | }
|
92 |
|
93 |
|
94 | const title = main.getName ? main.getName() : path.parse(file).name
|
95 | const mainTypeParams = main.getTypeParameters()
|
96 | definition.title = title
|
97 |
|
98 | if (mainTypeParams.length > 0) {
|
99 | throw new Error(
|
100 | `Generic Type Parameters are not supported for function "${title}"`
|
101 | )
|
102 | }
|
103 |
|
104 | const doc = main.getJsDocs()[0]
|
105 | let docs: doctrine.Annotation
|
106 |
|
107 | if (doc) {
|
108 | const { description } = doc.getStructure()
|
109 | docs = doctrine.parse(description as string)
|
110 | if (docs.description) {
|
111 | definition.description = docs.description
|
112 | }
|
113 | }
|
114 |
|
115 | const builder: FTS.DefinitionBuilder = {
|
116 | definition,
|
117 | docs,
|
118 | main,
|
119 | sourceFile,
|
120 | title
|
121 | }
|
122 |
|
123 | if (options.emit) {
|
124 | const result = project.emit(options.emitOptions)
|
125 | if (result.getEmitSkipped()) {
|
126 | throw new Error('emit skipped')
|
127 | }
|
128 | }
|
129 |
|
130 | addParamsDeclaration(builder)
|
131 | addReturnTypeDeclaration(builder)
|
132 |
|
133 |
|
134 |
|
135 | const tempSourceFilePath = path.format({
|
136 | dir: fileInfo.dir,
|
137 | ext: '.ts',
|
138 | name: `.${fileInfo.name}-fts`
|
139 | })
|
140 | const tempSourceFile = sourceFile.copy(tempSourceFilePath, {
|
141 | overwrite: true
|
142 | })
|
143 | await tempSourceFile.save()
|
144 |
|
145 | try {
|
146 | extractJSONSchemas(builder, tempSourceFilePath, jsonSchemaOptions)
|
147 | } finally {
|
148 | await fs.remove(tempSourceFilePath)
|
149 | }
|
150 |
|
151 | return builder.definition as FTS.Definition
|
152 | }
|
153 |
|
154 |
|
155 | function extractMainFunction(
|
156 | sourceFile: TS.SourceFile,
|
157 | definition: Partial<FTS.Definition>
|
158 | ): TS.FunctionDeclaration | undefined {
|
159 | const functionDefaultExports = sourceFile
|
160 | .getFunctions()
|
161 | .filter((f) => f.isDefaultExport())
|
162 |
|
163 | if (functionDefaultExports.length === 1) {
|
164 | definition.config.defaultExport = true
|
165 | return functionDefaultExports[0]
|
166 | } else {
|
167 | definition.config.defaultExport = false
|
168 | }
|
169 |
|
170 | const functionExports = sourceFile
|
171 | .getFunctions()
|
172 | .filter((f) => f.isExported())
|
173 |
|
174 | if (functionExports.length === 1) {
|
175 | const func = functionExports[0]
|
176 | definition.config.namedExport = func.getName()
|
177 | return func
|
178 | }
|
179 |
|
180 | if (functionExports.length > 1) {
|
181 | const externalFunctions = functionExports.filter((f) => {
|
182 | const docs = f.getJsDocs()[0]
|
183 |
|
184 | return (
|
185 | docs &&
|
186 | docs.getTags().find((tag) => {
|
187 | const tagName = tag.getTagName()
|
188 | return tagName === 'external' || tagName === 'public'
|
189 | })
|
190 | )
|
191 | })
|
192 |
|
193 | if (externalFunctions.length === 1) {
|
194 | const func = externalFunctions[0]
|
195 | definition.config.namedExport = func.getName()
|
196 | return func
|
197 | }
|
198 | }
|
199 |
|
200 |
|
201 | const arrowFunctionExports = sourceFile
|
202 | .getDescendantsOfKind(TS.SyntaxKind.ArrowFunction)
|
203 | .filter((f) => TS.TypeGuards.isExportAssignment(f.getParent()))
|
204 |
|
205 | if (arrowFunctionExports.length === 1) {
|
206 | const func = arrowFunctionExports[0]
|
207 | const exportAssignment = func.getParent() as TS.ExportAssignment
|
208 | const exportSymbol = sourceFile.getDefaultExportSymbol()
|
209 |
|
210 |
|
211 | if (exportSymbol) {
|
212 | const defaultExportPos = exportSymbol
|
213 | .getValueDeclarationOrThrow()
|
214 | .getPos()
|
215 | const exportAssignmentPos = exportAssignment.getPos()
|
216 |
|
217 |
|
218 | const isDefaultExport = defaultExportPos === exportAssignmentPos
|
219 |
|
220 | if (isDefaultExport) {
|
221 | definition.config.defaultExport = true
|
222 | return (func as unknown) as TS.FunctionDeclaration
|
223 | }
|
224 | }
|
225 | }
|
226 |
|
227 | return undefined
|
228 | }
|
229 |
|
230 | function addParamsDeclaration(
|
231 | builder: FTS.DefinitionBuilder
|
232 | ): TS.ClassDeclaration {
|
233 | const mainParams = builder.main.getParameters()
|
234 |
|
235 | const paramsDeclaration = builder.sourceFile.addClass({
|
236 | name: FTSParams
|
237 | })
|
238 |
|
239 | const paramComments = {}
|
240 |
|
241 | if (builder.docs) {
|
242 | const paramTags = builder.docs.tags.filter((tag) => tag.title === 'param')
|
243 | for (const tag of paramTags) {
|
244 | paramComments[tag.name] = tag.description
|
245 | }
|
246 | }
|
247 |
|
248 | for (let i = 0; i < mainParams.length; ++i) {
|
249 | const param = mainParams[i]
|
250 | const name = param.getName()
|
251 | const structure = param.getStructure()
|
252 |
|
253 |
|
254 |
|
255 | structure.type = param.getType().getText()
|
256 |
|
257 | if (name === 'context') {
|
258 | if (i !== mainParams.length - 1) {
|
259 | throw new Error(
|
260 | `Function parameter "context" must be last parameter to main function "${
|
261 | builder.title
|
262 | }"`
|
263 | )
|
264 | }
|
265 |
|
266 | builder.definition.params.context = true
|
267 |
|
268 |
|
269 | continue
|
270 | } else {
|
271 |
|
272 |
|
273 |
|
274 |
|
275 |
|
276 | }
|
277 |
|
278 | const promiseReMatch = structure.type.match(promiseTypeRe)
|
279 | if (promiseReMatch) {
|
280 | throw new Error(
|
281 | `Parameter "${name}" has unsupported type "${structure.type}"`
|
282 | )
|
283 | }
|
284 |
|
285 | addPropertyToDeclaration(
|
286 | paramsDeclaration,
|
287 | structure as TS.PropertyDeclarationStructure,
|
288 | paramComments[name]
|
289 | )
|
290 | builder.definition.params.order.push(name)
|
291 | }
|
292 |
|
293 | return paramsDeclaration
|
294 | }
|
295 |
|
296 | function addReturnTypeDeclaration(builder: FTS.DefinitionBuilder) {
|
297 | const mainReturnType = builder.main.getReturnType()
|
298 | let type = mainReturnType.getText()
|
299 |
|
300 | const promiseReMatch = type.match(promiseTypeRe)
|
301 | const isAsync = !!promiseReMatch
|
302 |
|
303 | builder.definition.returns.async = builder.main.isAsync()
|
304 |
|
305 | if (isAsync) {
|
306 | type = promiseReMatch[1]
|
307 | builder.definition.returns.async = true
|
308 | }
|
309 |
|
310 | if (type === 'void') {
|
311 | type = 'any'
|
312 | }
|
313 |
|
314 | if (
|
315 | type.endsWith('HttpResponse') &&
|
316 | (isAsync || mainReturnType.isInterface())
|
317 | ) {
|
318 | builder.definition.returns.http = true
|
319 | }
|
320 |
|
321 | const declaration = builder.sourceFile.addInterface({
|
322 | name: FTSReturns
|
323 | })
|
324 |
|
325 | const jsdoc =
|
326 | builder.docs &&
|
327 | builder.docs.tags.find(
|
328 | (tag) => tag.title === 'returns' || tag.title === 'return'
|
329 | )
|
330 | addPropertyToDeclaration(
|
331 | declaration,
|
332 | { name: 'result', type },
|
333 | jsdoc && jsdoc.description
|
334 | )
|
335 | }
|
336 |
|
337 | function addPropertyToDeclaration(
|
338 | declaration: TS.ClassDeclaration | TS.InterfaceDeclaration,
|
339 | structure: TS.PropertyDeclarationStructure,
|
340 | jsdoc?: string
|
341 | ): TS.PropertyDeclaration | TS.PropertySignature {
|
342 | const isDate = structure.type === 'Date'
|
343 | const isBuffer = structure.type === 'Buffer'
|
344 |
|
345 |
|
346 | if (isDate || isBuffer) {
|
347 | const coercionType = structure.type
|
348 |
|
349 | if (isDate) {
|
350 | structure.type = 'Date'
|
351 | } else {
|
352 | structure.type = 'string'
|
353 | }
|
354 |
|
355 | jsdoc = `${jsdoc ? jsdoc + '\n' : ''}@coerceTo ${coercionType}`
|
356 | }
|
357 |
|
358 | const property = declaration.addProperty(structure)
|
359 |
|
360 | if (jsdoc) {
|
361 | property.addJsDoc(jsdoc)
|
362 | }
|
363 |
|
364 | return property
|
365 | }
|
366 |
|
367 | function extractJSONSchemas(
|
368 | builder: FTS.DefinitionBuilder,
|
369 | file: string,
|
370 | jsonSchemaOptions: TJS.PartialArgs = {},
|
371 | jsonCompilerOptions: any = {}
|
372 | ) {
|
373 | const compilerOptions = {
|
374 | allowJs: true,
|
375 | lib: ['es2018', 'dom'],
|
376 | target: 'es5',
|
377 | ...jsonCompilerOptions
|
378 | }
|
379 |
|
380 | const program = TJS.getProgramFromFiles(
|
381 | [file],
|
382 | compilerOptions,
|
383 | process.cwd()
|
384 | )
|
385 |
|
386 | builder.definition.params.schema = TJS.generateSchema(
|
387 | program,
|
388 | FTSParams,
|
389 | jsonSchemaOptions
|
390 | )
|
391 |
|
392 | if (!builder.definition.params.schema) {
|
393 | throw new Error(`Error generating params JSON schema for TS file "${file}"`)
|
394 | }
|
395 |
|
396 | builder.definition.returns.schema = TJS.generateSchema(program, FTSReturns, {
|
397 | ...jsonSchemaOptions,
|
398 | required: false
|
399 | })
|
400 |
|
401 | if (!builder.definition.returns.schema) {
|
402 | throw new Error(
|
403 | `Error generating returns JSON schema for TS file "${file}"`
|
404 | )
|
405 | }
|
406 | }
|
407 |
|
408 |
|
409 |
|
410 |
|
411 |
|
412 |
|
413 |
|
414 |
|
415 |
|
416 |
|
417 |
|
418 |
|
419 |
|
420 |
|