moonflower 1.4.8 → 1.5.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.
- package/dist/openapi/analyzerModule/analyzerModule.cjs +1 -1
- package/dist/openapi/analyzerModule/analyzerModule.cjs.map +1 -1
- package/dist/openapi/analyzerModule/analyzerModule.d.ts +18 -3
- package/dist/openapi/analyzerModule/analyzerModule.d.ts.map +1 -1
- package/dist/openapi/analyzerModule/analyzerModule.mjs +168 -112
- package/dist/openapi/analyzerModule/analyzerModule.mjs.map +1 -1
- package/dist/openapi/analyzerModule/analyzerWorker.cjs +2 -0
- package/dist/openapi/analyzerModule/analyzerWorker.cjs.map +1 -0
- package/dist/openapi/analyzerModule/analyzerWorker.d.ts +2 -0
- package/dist/openapi/analyzerModule/analyzerWorker.d.ts.map +1 -0
- package/dist/openapi/analyzerModule/analyzerWorker.mjs +44 -0
- package/dist/openapi/analyzerModule/analyzerWorker.mjs.map +1 -0
- package/dist/openapi/analyzerModule/nodeParsers.cjs +1 -1
- package/dist/openapi/analyzerModule/nodeParsers.cjs.map +1 -1
- package/dist/openapi/analyzerModule/nodeParsers.d.ts.map +1 -1
- package/dist/openapi/analyzerModule/nodeParsers.mjs +199 -264
- package/dist/openapi/analyzerModule/nodeParsers.mjs.map +1 -1
- package/dist/openapi/analyzerModule/parseEndpoint.cjs +1 -1
- package/dist/openapi/analyzerModule/parseEndpoint.cjs.map +1 -1
- package/dist/openapi/analyzerModule/parseEndpoint.d.ts +8 -1
- package/dist/openapi/analyzerModule/parseEndpoint.d.ts.map +1 -1
- package/dist/openapi/analyzerModule/parseEndpoint.mjs +121 -106
- package/dist/openapi/analyzerModule/parseEndpoint.mjs.map +1 -1
- package/dist/openapi/analyzerModule/test/TestCase.d.ts +1 -0
- package/dist/openapi/analyzerModule/test/TestCase.d.ts.map +1 -1
- package/dist/openapi/analyzerModule/test/workerGlobalSetup.d.ts +3 -0
- package/dist/openapi/analyzerModule/test/workerGlobalSetup.d.ts.map +1 -0
- package/dist/openapi/analyzerModule/workerPaths.d.ts +2 -0
- package/dist/openapi/analyzerModule/workerPaths.d.ts.map +1 -0
- package/dist/openapi/analyzerModule/workerPool.cjs +2 -0
- package/dist/openapi/analyzerModule/workerPool.cjs.map +1 -0
- package/dist/openapi/analyzerModule/workerPool.d.ts +33 -0
- package/dist/openapi/analyzerModule/workerPool.d.ts.map +1 -0
- package/dist/openapi/analyzerModule/workerPool.mjs +46 -0
- package/dist/openapi/analyzerModule/workerPool.mjs.map +1 -0
- package/package.json +1 -1
- package/src/openapi/analyzerModule/analyzerModule.ts +198 -25
- package/src/openapi/analyzerModule/analyzerWorker.ts +73 -0
- package/src/openapi/analyzerModule/nodeParsers.ts +4 -104
- package/src/openapi/analyzerModule/parseEndpoint.ts +31 -9
- package/src/openapi/analyzerModule/test/TestCase.ts +1 -0
- package/src/openapi/analyzerModule/test/openApiAnalyzer.spec.ts +2 -2
- package/src/openapi/analyzerModule/test/openApiAnalyzer.zod.spec.data.ts +6 -0
- package/src/openapi/analyzerModule/test/openApiAnalyzer.zod.spec.ts +9 -1
- package/src/openapi/analyzerModule/test/workerGlobalSetup.ts +25 -0
- package/src/openapi/analyzerModule/workerPaths.ts +4 -0
- package/src/openapi/analyzerModule/workerPool.ts +92 -0
- package/src/test/app.spec.ts +10 -1
- package/vite.config.ts +5 -0
|
@@ -1,4 +1,20 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
1
3
|
import * as path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
function resolveWorkerUrl(): URL {
|
|
7
|
+
const candidates = ['./analyzerWorker.mjs', './analyzerWorker.test.mjs']
|
|
8
|
+
for (const candidate of candidates) {
|
|
9
|
+
const url = new URL(candidate, import.meta.url)
|
|
10
|
+
if (existsSync(fileURLToPath(url))) {
|
|
11
|
+
return url
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
throw new Error(
|
|
15
|
+
'analyzerWorker.mjs not found. Run yarn build, or run tests via yarn test (which compiles the worker first).',
|
|
16
|
+
)
|
|
17
|
+
}
|
|
2
18
|
import { SourceFile, SyntaxKind } from 'ts-morph'
|
|
3
19
|
import { Project } from 'ts-morph'
|
|
4
20
|
|
|
@@ -13,9 +29,10 @@ import { ApiDocsHeader, OpenApiManager } from '../manager/OpenApiManager'
|
|
|
13
29
|
import { EndpointData, ExposedModelData } from '../types'
|
|
14
30
|
import { getSourceFileTimestamp, TimestampCache } from './getSourceFileTimestamp'
|
|
15
31
|
import { getValuesOfObjectLiteral, resolveEndpointPath } from './nodeParsers'
|
|
16
|
-
import { parseEndpoint } from './parseEndpoint'
|
|
32
|
+
import { parseEndpoint, SectionTiming } from './parseEndpoint'
|
|
17
33
|
import { parseExposedModel, parseNamedExposedModels } from './parseExposedModels'
|
|
18
34
|
import { SourceFileCache } from './sourceFileCache'
|
|
35
|
+
import { WorkerPool, WorkerResult, WorkerTask } from './workerPool'
|
|
19
36
|
|
|
20
37
|
type Props = {
|
|
21
38
|
logLevel?: Parameters<(typeof Logger)['setLevel']>[0]
|
|
@@ -27,23 +44,27 @@ type Props = {
|
|
|
27
44
|
| {
|
|
28
45
|
cachePath: string
|
|
29
46
|
}
|
|
47
|
+
profiling?: 'stats' | 'off' | 'debug'
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
type FileDiscoveryConfig = {
|
|
33
51
|
rootPath: string
|
|
34
52
|
}
|
|
35
53
|
|
|
54
|
+
type EndpointTiming = { method: string; path: string; timing: number; sectionTimings: SectionTiming[] }
|
|
55
|
+
|
|
36
56
|
/**
|
|
37
57
|
* @param tsconfigPath Path to tsconfig file relative to project root
|
|
38
58
|
* @param sourceFilePaths Array of router source files relative to project root
|
|
39
59
|
*/
|
|
40
|
-
export const prepareOpenApiSpec = ({
|
|
60
|
+
export const prepareOpenApiSpec = async ({
|
|
41
61
|
logLevel,
|
|
42
62
|
tsconfigPath,
|
|
43
63
|
sourceFilePaths,
|
|
44
64
|
sourceFileDiscovery,
|
|
45
65
|
incremental,
|
|
46
|
-
|
|
66
|
+
profiling = 'stats',
|
|
67
|
+
}: Props): Promise<void> => {
|
|
47
68
|
const openApiManager = OpenApiManager.getInstance()
|
|
48
69
|
|
|
49
70
|
if (openApiManager.isReady()) {
|
|
@@ -81,7 +102,9 @@ export const prepareOpenApiSpec = ({
|
|
|
81
102
|
targetPath: typeof sourceFileDiscovery === 'object' ? sourceFileDiscovery.rootPath : '.',
|
|
82
103
|
tsConfigPath: tsconfigPath,
|
|
83
104
|
})
|
|
84
|
-
|
|
105
|
+
if (profiling !== 'off') {
|
|
106
|
+
Logger.info(`File discovery took ${Math.round(performance.now() - startTime)}ms`)
|
|
107
|
+
}
|
|
85
108
|
return files
|
|
86
109
|
})()
|
|
87
110
|
|
|
@@ -116,10 +139,12 @@ export const prepareOpenApiSpec = ({
|
|
|
116
139
|
}
|
|
117
140
|
return path.resolve(process.cwd(), 'node_modules', '.cache', 'moonflower')
|
|
118
141
|
})()
|
|
119
|
-
const endpoints = analyzeMultipleSourceFiles(filesToAnalyze, {
|
|
142
|
+
const endpoints = await analyzeMultipleSourceFiles(filesToAnalyze, {
|
|
120
143
|
incremental: incremental !== false,
|
|
121
144
|
cachePath,
|
|
122
145
|
timestampCache: {},
|
|
146
|
+
profiling,
|
|
147
|
+
tsconfigPath: path.resolve(tsconfigPath),
|
|
123
148
|
})
|
|
124
149
|
|
|
125
150
|
openApiManager.setStats({
|
|
@@ -147,29 +172,167 @@ export const prepareOpenApiSpec = ({
|
|
|
147
172
|
openApiManager.markAsReady()
|
|
148
173
|
}
|
|
149
174
|
|
|
150
|
-
export const analyzeMultipleSourceFiles = (
|
|
175
|
+
export const analyzeMultipleSourceFiles = async (
|
|
151
176
|
files: DiscoveredSourceFile[],
|
|
152
177
|
config: {
|
|
153
178
|
incremental: boolean
|
|
154
179
|
cachePath: string
|
|
155
180
|
timestampCache: TimestampCache
|
|
181
|
+
profiling?: 'stats' | 'off' | 'debug'
|
|
182
|
+
tsconfigPath: string
|
|
156
183
|
},
|
|
157
184
|
filterEndpointPaths?: string[],
|
|
158
|
-
): EndpointData[] => {
|
|
185
|
+
): Promise<EndpointData[]> => {
|
|
186
|
+
const profiling = config.profiling ?? 'stats'
|
|
159
187
|
const startTime = performance.now()
|
|
160
|
-
const analyzedFiles = files.map((file) => analyzeSourceFileWithCache(file, config, filterEndpointPaths))
|
|
161
|
-
Logger.info(`Router analysis took ${Math.round(performance.now() - startTime)}ms`)
|
|
162
188
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
189
|
+
// Separate cached files from those needing analysis
|
|
190
|
+
type CachedFile = { endpoints: EndpointData[]; fileName: string; timing: 0; endpointTimings: [] }
|
|
191
|
+
type UncachedFile = { file: DiscoveredSourceFile; timestamp: number }
|
|
192
|
+
|
|
193
|
+
const cached: CachedFile[] = []
|
|
194
|
+
const uncached: UncachedFile[] = []
|
|
195
|
+
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
const timestamp = getSourceFileTimestamp(file.sourceFile, config.timestampCache)
|
|
198
|
+
const hit = config.incremental
|
|
199
|
+
? SourceFileCache.getCachedResults(file.sourceFile, timestamp, config.cachePath)
|
|
200
|
+
: null
|
|
201
|
+
if (hit) {
|
|
202
|
+
Logger.debug(`[${file.fileName}] Found cached results`)
|
|
203
|
+
cached.push({ endpoints: hit.endpoints, fileName: file.fileName, timing: 0, endpointTimings: [] })
|
|
204
|
+
} else {
|
|
205
|
+
uncached.push({ file, timestamp })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (uncached.length === 0) {
|
|
210
|
+
if (profiling !== 'off') {
|
|
211
|
+
Logger.info(`Router analysis took ${Math.round(performance.now() - startTime)}ms`)
|
|
212
|
+
}
|
|
213
|
+
return cached.flatMap((f) => f.endpoints)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Build one task per endpoint across all uncached files
|
|
217
|
+
const OPERATIONS = ['get', 'post', 'put', 'delete', 'del', 'patch']
|
|
218
|
+
const operationsPattern = OPERATIONS.join('|')
|
|
219
|
+
|
|
220
|
+
type FileTask = { task: WorkerTask; fileName: string }
|
|
221
|
+
const allTasks: FileTask[] = []
|
|
222
|
+
|
|
223
|
+
for (const { file } of uncached) {
|
|
224
|
+
for (const routerName of file.routers.named) {
|
|
225
|
+
const routerPattern = new RegExp(`${routerName}\\.(?:${operationsPattern})`)
|
|
226
|
+
let endpointIndex = 0
|
|
227
|
+
file.sourceFile.forEachChild((node) => {
|
|
228
|
+
const nodeText = node.getText()
|
|
229
|
+
if (routerPattern.test(nodeText)) {
|
|
230
|
+
if (
|
|
231
|
+
!filterEndpointPaths ||
|
|
232
|
+
filterEndpointPaths.some((p) => resolveEndpointPath(node)?.includes(p))
|
|
233
|
+
) {
|
|
234
|
+
allTasks.push({
|
|
235
|
+
fileName: file.fileName,
|
|
236
|
+
task: {
|
|
237
|
+
taskId: crypto.randomUUID(),
|
|
238
|
+
tsconfigPath: config.tsconfigPath,
|
|
239
|
+
sourceFilePath: file.sourceFile.getFilePath(),
|
|
240
|
+
routerName,
|
|
241
|
+
endpointIndex,
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
endpointIndex++
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Dispatch all tasks to the worker pool
|
|
252
|
+
const pool = new WorkerPool(resolveWorkerUrl())
|
|
253
|
+
|
|
254
|
+
type EndpointTiming = { method: string; path: string; timing: number; sectionTimings: SectionTiming[] }
|
|
255
|
+
type FileResult = {
|
|
256
|
+
endpoints: EndpointData[]
|
|
257
|
+
fileName: string
|
|
258
|
+
timing: number
|
|
259
|
+
endpointTimings: EndpointTiming[]
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let results: WorkerResult[]
|
|
263
|
+
try {
|
|
264
|
+
results = await pool.runAll(allTasks.map((ft) => ft.task))
|
|
265
|
+
} finally {
|
|
266
|
+
pool.terminate()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Group results by file
|
|
270
|
+
const byFile = new Map<string, FileResult>()
|
|
271
|
+
for (const { file } of uncached) {
|
|
272
|
+
byFile.set(file.fileName, { endpoints: [], fileName: file.fileName, timing: 0, endpointTimings: [] })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (let i = 0; i < results.length; i++) {
|
|
276
|
+
const result = results[i]
|
|
277
|
+
const fileName = allTasks[i].fileName
|
|
278
|
+
const fileResult = byFile.get(fileName)!
|
|
279
|
+
|
|
280
|
+
if ('error' in result) {
|
|
281
|
+
Logger.error(`[${fileName}] Worker error: ${result.error}`)
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
fileResult.endpoints.push(result.endpoint)
|
|
286
|
+
fileResult.timing += result.timing
|
|
287
|
+
fileResult.endpointTimings.push({
|
|
288
|
+
method: result.endpoint.method,
|
|
289
|
+
path: result.endpoint.path,
|
|
290
|
+
timing: result.timing,
|
|
291
|
+
sectionTimings: result.sectionTimings,
|
|
172
292
|
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Write cache for each uncached file
|
|
296
|
+
for (const { file, timestamp } of uncached) {
|
|
297
|
+
const fileResult = byFile.get(file.fileName)!
|
|
298
|
+
if (fileResult.endpoints.length > 0) {
|
|
299
|
+
SourceFileCache.cacheResults(file.sourceFile, timestamp, config.cachePath, fileResult.endpoints)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const analyzedFiles = [...cached, ...Array.from(byFile.values())]
|
|
304
|
+
|
|
305
|
+
if (profiling !== 'off') {
|
|
306
|
+
Logger.info(`Router analysis took ${Math.round(performance.now() - startTime)}ms`)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (profiling === 'stats') {
|
|
310
|
+
analyzedFiles
|
|
311
|
+
.map((f) => ({ fileName: f.fileName, timeTaken: f.timing }))
|
|
312
|
+
.sort((a, b) => b.timeTaken - a.timeTaken)
|
|
313
|
+
.filter((t) => t.timeTaken > 500)
|
|
314
|
+
.forEach((t) => {
|
|
315
|
+
Logger.info(`- [${t.fileName}] Took ${Math.round(t.timeTaken)}ms to analyze`)
|
|
316
|
+
})
|
|
317
|
+
} else if (profiling === 'debug') {
|
|
318
|
+
analyzedFiles
|
|
319
|
+
.map((f) => ({ fileName: f.fileName, timeTaken: f.timing, endpointTimings: f.endpointTimings }))
|
|
320
|
+
.sort((a, b) => b.timeTaken - a.timeTaken)
|
|
321
|
+
.forEach((t) => {
|
|
322
|
+
Logger.info(`- [${t.fileName}] Took ${Math.round(t.timeTaken)}ms to analyze`)
|
|
323
|
+
t.endpointTimings
|
|
324
|
+
.sort((a, b) => b.timing - a.timing)
|
|
325
|
+
.forEach((ep) => {
|
|
326
|
+
Logger.info(` - ${ep.method} ${ep.path} (${Math.round(ep.timing)}ms)`)
|
|
327
|
+
ep.sectionTimings
|
|
328
|
+
.filter((s) => s.timing >= 1)
|
|
329
|
+
.sort((a, b) => b.timing - a.timing)
|
|
330
|
+
.forEach((s) => {
|
|
331
|
+
Logger.info(` - ${s.section}: ${Math.round(s.timing)}ms`)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
}
|
|
173
336
|
|
|
174
337
|
return analyzedFiles.flatMap((f) => f.endpoints)
|
|
175
338
|
}
|
|
@@ -180,31 +343,33 @@ export const analyzeSourceFileWithCache = (
|
|
|
180
343
|
incremental: boolean
|
|
181
344
|
cachePath: string
|
|
182
345
|
timestampCache: TimestampCache
|
|
346
|
+
profiling?: 'stats' | 'off' | 'debug'
|
|
183
347
|
},
|
|
184
348
|
filterEndpointPaths?: string[],
|
|
185
|
-
): { endpoints: EndpointData[]; timing: number } => {
|
|
349
|
+
): { endpoints: EndpointData[]; timing: number; endpointTimings: EndpointTiming[] } => {
|
|
186
350
|
const timestamp = getSourceFileTimestamp(file.sourceFile, config.timestampCache)
|
|
187
351
|
const cachedResults = SourceFileCache.getCachedResults(file.sourceFile, timestamp, config.cachePath)
|
|
188
352
|
|
|
189
353
|
if (cachedResults) {
|
|
190
354
|
Logger.debug(`[${file.fileName}] Found cached results`)
|
|
191
|
-
return { endpoints: cachedResults.endpoints, timing: 0 }
|
|
355
|
+
return { endpoints: cachedResults.endpoints, timing: 0, endpointTimings: [] }
|
|
192
356
|
}
|
|
193
357
|
Logger.debug(`[${file.fileName}] Analyzing...`)
|
|
194
358
|
|
|
195
359
|
const t1 = performance.now()
|
|
196
|
-
const endpoints = analyzeSourceFileEndpoints(file, filterEndpointPaths)
|
|
360
|
+
const { endpoints, endpointTimings } = analyzeSourceFileEndpoints(file, filterEndpointPaths)
|
|
197
361
|
const t2 = performance.now()
|
|
198
362
|
Logger.debug(`[${file.fileName}] Analyzed in ${t2 - t1}ms`)
|
|
199
363
|
SourceFileCache.cacheResults(file.sourceFile, timestamp, config.cachePath, endpoints)
|
|
200
|
-
return { endpoints, timing: t2 - t1 }
|
|
364
|
+
return { endpoints, timing: t2 - t1, endpointTimings }
|
|
201
365
|
}
|
|
202
366
|
|
|
203
367
|
export const analyzeSourceFileEndpoints = (
|
|
204
368
|
file: DiscoveredSourceFile,
|
|
205
369
|
filterEndpointPaths?: string[],
|
|
206
|
-
): EndpointData[] => {
|
|
370
|
+
): { endpoints: EndpointData[]; endpointTimings: EndpointTiming[] } => {
|
|
207
371
|
const endpoints: EndpointData[] = []
|
|
372
|
+
const endpointTimings: EndpointTiming[] = []
|
|
208
373
|
const operations = ['get', 'post', 'put', 'delete', 'del', 'patch']
|
|
209
374
|
const joinedOperations = operations.join('|')
|
|
210
375
|
|
|
@@ -220,12 +385,20 @@ export const analyzeSourceFileEndpoints = (
|
|
|
220
385
|
return
|
|
221
386
|
}
|
|
222
387
|
|
|
223
|
-
|
|
388
|
+
const t1 = performance.now()
|
|
389
|
+
const { endpoint, sectionTimings } = parseEndpoint(node, file.fileName)
|
|
390
|
+
endpointTimings.push({
|
|
391
|
+
method: endpoint.method,
|
|
392
|
+
path: endpoint.path,
|
|
393
|
+
timing: performance.now() - t1,
|
|
394
|
+
sectionTimings,
|
|
395
|
+
})
|
|
396
|
+
endpoints.push(endpoint)
|
|
224
397
|
}
|
|
225
398
|
})
|
|
226
399
|
})
|
|
227
400
|
|
|
228
|
-
return endpoints
|
|
401
|
+
return { endpoints, endpointTimings }
|
|
229
402
|
}
|
|
230
403
|
|
|
231
404
|
export const analyzeSourceFileApiHeader = (sourceFile: SourceFile): ApiDocsHeader | null => {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Node, Project, ts } from 'ts-morph'
|
|
2
|
+
import { parentPort } from 'worker_threads'
|
|
3
|
+
|
|
4
|
+
import { parseEndpoint } from './parseEndpoint'
|
|
5
|
+
import { WorkerResult, WorkerTask } from './workerPool'
|
|
6
|
+
|
|
7
|
+
const OPERATIONS = ['get', 'post', 'put', 'delete', 'del', 'patch']
|
|
8
|
+
const OPERATIONS_PATTERN = OPERATIONS.join('|')
|
|
9
|
+
|
|
10
|
+
let project: Project | null = null
|
|
11
|
+
let currentTsconfigPath: string | null = null
|
|
12
|
+
|
|
13
|
+
function getProject(tsconfigPath: string): Project {
|
|
14
|
+
if (!project || currentTsconfigPath !== tsconfigPath) {
|
|
15
|
+
project = new Project({
|
|
16
|
+
tsConfigFilePath: tsconfigPath,
|
|
17
|
+
skipFileDependencyResolution: true,
|
|
18
|
+
})
|
|
19
|
+
currentTsconfigPath = tsconfigPath
|
|
20
|
+
}
|
|
21
|
+
return project
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
parentPort!.on('message', (task: WorkerTask) => {
|
|
25
|
+
try {
|
|
26
|
+
const proj = getProject(task.tsconfigPath)
|
|
27
|
+
|
|
28
|
+
let sourceFile = proj.getSourceFile(task.sourceFilePath)
|
|
29
|
+
if (!sourceFile) {
|
|
30
|
+
sourceFile = proj.addSourceFileAtPath(task.sourceFilePath)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const routerPattern = new RegExp(`${task.routerName}\\.(?:${OPERATIONS_PATTERN})`)
|
|
34
|
+
let index = 0
|
|
35
|
+
let targetNode: Node<ts.Node> | undefined
|
|
36
|
+
|
|
37
|
+
sourceFile.forEachChild((node) => {
|
|
38
|
+
if (targetNode) return
|
|
39
|
+
if (routerPattern.test(node.getText())) {
|
|
40
|
+
if (index === task.endpointIndex) {
|
|
41
|
+
targetNode = node
|
|
42
|
+
}
|
|
43
|
+
index++
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (!targetNode) {
|
|
48
|
+
const result: WorkerResult = {
|
|
49
|
+
taskId: task.taskId,
|
|
50
|
+
error: `Endpoint not found: routerName=${task.routerName} index=${task.endpointIndex} in ${task.sourceFilePath}`,
|
|
51
|
+
}
|
|
52
|
+
parentPort!.postMessage(result)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const t1 = performance.now()
|
|
57
|
+
const { endpoint, sectionTimings } = parseEndpoint(targetNode, task.sourceFilePath)
|
|
58
|
+
|
|
59
|
+
const result: WorkerResult = {
|
|
60
|
+
taskId: task.taskId,
|
|
61
|
+
endpoint,
|
|
62
|
+
sectionTimings,
|
|
63
|
+
timing: performance.now() - t1,
|
|
64
|
+
}
|
|
65
|
+
parentPort!.postMessage(result)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const result: WorkerResult = {
|
|
68
|
+
taskId: task.taskId,
|
|
69
|
+
error: String(err),
|
|
70
|
+
}
|
|
71
|
+
parentPort!.postMessage(result)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
@@ -278,112 +278,12 @@ const isZodCallExpression = (node: Node): boolean => {
|
|
|
278
278
|
const getZodCallShape = (node: Node): ShapeOfType['shape'] => {
|
|
279
279
|
const callExpression = node.asKind(SyntaxKind.CallExpression)!
|
|
280
280
|
const returnType = callExpression.getReturnType()
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return 'number'
|
|
285
|
-
}
|
|
286
|
-
if (typeName === 'ZodString') {
|
|
287
|
-
return 'string'
|
|
288
|
-
}
|
|
289
|
-
if (typeName === 'ZodBoolean') {
|
|
290
|
-
return 'boolean'
|
|
291
|
-
}
|
|
292
|
-
if (typeName === 'ZodBigInt') {
|
|
293
|
-
return 'bigint'
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (typeName === 'ZodObject') {
|
|
297
|
-
const argNode = callExpression.getFirstChildByKind(SyntaxKind.SyntaxList)?.getFirstChild()
|
|
298
|
-
const objectLiteral = argNode?.asKind(SyntaxKind.ObjectLiteralExpression)
|
|
299
|
-
if (!objectLiteral) {
|
|
300
|
-
return 'unknown_zod_object'
|
|
301
|
-
}
|
|
302
|
-
const syntaxList = objectLiteral.getFirstChildByKind(SyntaxKind.SyntaxList)
|
|
303
|
-
if (!syntaxList) {
|
|
304
|
-
return []
|
|
305
|
-
}
|
|
306
|
-
const properties = syntaxList.getChildrenOfKind(SyntaxKind.PropertyAssignment)
|
|
307
|
-
return properties.map((prop) => {
|
|
308
|
-
const identifier = prop.getFirstChildByKind(SyntaxKind.Identifier)!.getText()
|
|
309
|
-
const valueNode = prop.getLastChild()!
|
|
310
|
-
return {
|
|
311
|
-
role: 'property' as const,
|
|
312
|
-
identifier,
|
|
313
|
-
shape: isZodCallExpression(valueNode)
|
|
314
|
-
? getZodCallShape(valueNode)
|
|
315
|
-
: getValidatorPropertyShape(valueNode),
|
|
316
|
-
optional: false,
|
|
317
|
-
}
|
|
318
|
-
})
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (typeName === 'ZodArray') {
|
|
322
|
-
const argNode = callExpression.getFirstChildByKind(SyntaxKind.SyntaxList)?.getFirstChild()
|
|
323
|
-
if (argNode) {
|
|
324
|
-
const elementShape = isZodCallExpression(argNode)
|
|
325
|
-
? getZodCallShape(argNode)
|
|
326
|
-
: getValidatorPropertyShape(argNode)
|
|
327
|
-
return [
|
|
328
|
-
{
|
|
329
|
-
role: 'array' as const,
|
|
330
|
-
shape: elementShape,
|
|
331
|
-
optional: false,
|
|
332
|
-
},
|
|
333
|
-
]
|
|
334
|
-
}
|
|
335
|
-
// Handle chained form: z.string().array()
|
|
336
|
-
const propertyAccess = callExpression.getFirstChildByKind(SyntaxKind.PropertyAccessExpression)
|
|
337
|
-
const receiverCall = propertyAccess?.getFirstChildByKind(SyntaxKind.CallExpression)
|
|
338
|
-
if (receiverCall && isZodCallExpression(receiverCall)) {
|
|
339
|
-
return [
|
|
340
|
-
{
|
|
341
|
-
role: 'array' as const,
|
|
342
|
-
shape: getZodCallShape(receiverCall),
|
|
343
|
-
optional: false,
|
|
344
|
-
},
|
|
345
|
-
]
|
|
346
|
-
}
|
|
347
|
-
return 'unknown_zod_array'
|
|
281
|
+
const outputProp = returnType.getProperty('_output')
|
|
282
|
+
if (outputProp) {
|
|
283
|
+
return getProperTypeShape(outputProp.getTypeAtLocation(callExpression), callExpression)
|
|
348
284
|
}
|
|
349
|
-
|
|
350
|
-
if (typeName === 'ZodEnum') {
|
|
351
|
-
const typeArgs = returnType.getTypeArguments()
|
|
352
|
-
if (typeArgs.length > 0) {
|
|
353
|
-
const enumType = typeArgs[0]
|
|
354
|
-
const properties = enumType.getProperties()
|
|
355
|
-
const shapes: ShapeOfUnionEntry[] = properties.map((prop) => ({
|
|
356
|
-
role: 'union_entry' as const,
|
|
357
|
-
shape: getProperTypeShape(prop.getTypeAtLocation(callExpression), callExpression, []),
|
|
358
|
-
optional: false,
|
|
359
|
-
}))
|
|
360
|
-
if (shapes.length === 1) {
|
|
361
|
-
return shapes[0].shape
|
|
362
|
-
}
|
|
363
|
-
if (shapes.length > 1) {
|
|
364
|
-
return [
|
|
365
|
-
{
|
|
366
|
-
role: 'union' as const,
|
|
367
|
-
shape: shapes,
|
|
368
|
-
optional: false,
|
|
369
|
-
},
|
|
370
|
-
]
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
return 'unknown_zod_enum'
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (typeName === 'ZodOptional') {
|
|
377
|
-
const innerCallExpression = callExpression
|
|
378
|
-
.getFirstChildByKind(SyntaxKind.PropertyAccessExpression)
|
|
379
|
-
?.getFirstChildByKind(SyntaxKind.CallExpression)
|
|
380
|
-
if (innerCallExpression && isZodCallExpression(innerCallExpression)) {
|
|
381
|
-
return getZodCallShape(innerCallExpression)
|
|
382
|
-
}
|
|
383
|
-
return 'unknown_zod_optional'
|
|
384
|
-
}
|
|
385
|
-
|
|
386
285
|
const fileName = node.getSourceFile().getFilePath().split('/').pop()
|
|
286
|
+
const typeName = returnType.getSymbol()?.getName() ?? ''
|
|
387
287
|
Logger.warn(`[${fileName}] Unknown zod type: ${typeName}`)
|
|
388
288
|
return 'unknown_zod'
|
|
389
289
|
}
|
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
resolveEndpointPath,
|
|
15
15
|
} from './nodeParsers'
|
|
16
16
|
|
|
17
|
-
export
|
|
17
|
+
export type SectionTiming = { section: string; timing: number }
|
|
18
|
+
|
|
19
|
+
export const parseEndpoint = (
|
|
20
|
+
node: Node<ts.Node>,
|
|
21
|
+
sourceFilePath: string,
|
|
22
|
+
): { endpoint: EndpointData; sectionTimings: SectionTiming[] } => {
|
|
18
23
|
const parsedEndpointMethod = node
|
|
19
24
|
.getFirstDescendantByKind(SyntaxKind.PropertyAccessExpression)!
|
|
20
25
|
.getText()
|
|
@@ -46,11 +51,20 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
|
|
|
46
51
|
error: Error
|
|
47
52
|
}[] = []
|
|
48
53
|
|
|
54
|
+
const sectionTimings: SectionTiming[] = []
|
|
55
|
+
|
|
56
|
+
const time = <T>(section: string, fn: () => T): T => {
|
|
57
|
+
const t1 = performance.now()
|
|
58
|
+
const result = fn()
|
|
59
|
+
sectionTimings.push({ section, timing: performance.now() - t1 })
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
const hookNodes = getHookNodes(node)
|
|
50
64
|
|
|
51
65
|
// API documentation
|
|
52
66
|
try {
|
|
53
|
-
const entries = parseApiDocumentation(hookNodes.useApiEndpoint)
|
|
67
|
+
const entries = time('useApiEndpoint', () => parseApiDocumentation(hookNodes.useApiEndpoint))
|
|
54
68
|
entries.forEach((param) => {
|
|
55
69
|
endpointData[param.identifier] = param.value as string & string[]
|
|
56
70
|
})
|
|
@@ -64,7 +78,9 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
|
|
|
64
78
|
|
|
65
79
|
// Request params
|
|
66
80
|
try {
|
|
67
|
-
endpointData.requestPathParams =
|
|
81
|
+
endpointData.requestPathParams = time('usePathParams', () =>
|
|
82
|
+
parseRequestParams(hookNodes.usePathParams, endpointPath),
|
|
83
|
+
)
|
|
68
84
|
} catch (err) {
|
|
69
85
|
warningData.push({
|
|
70
86
|
segment: 'path',
|
|
@@ -75,7 +91,9 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
|
|
|
75
91
|
|
|
76
92
|
// Request query
|
|
77
93
|
try {
|
|
78
|
-
endpointData.requestQuery =
|
|
94
|
+
endpointData.requestQuery = time('useQueryParams', () =>
|
|
95
|
+
parseRequestObjectInput(hookNodes.useQueryParams, 'useQueryParams'),
|
|
96
|
+
)
|
|
79
97
|
} catch (err) {
|
|
80
98
|
warningData.push({
|
|
81
99
|
segment: 'query',
|
|
@@ -86,7 +104,9 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
|
|
|
86
104
|
|
|
87
105
|
// Request headers
|
|
88
106
|
try {
|
|
89
|
-
endpointData.requestHeaders =
|
|
107
|
+
endpointData.requestHeaders = time('useHeaderParams', () =>
|
|
108
|
+
parseRequestObjectInput(hookNodes.useHeaderParams, 'useHeaderParams'),
|
|
109
|
+
)
|
|
90
110
|
} catch (err) {
|
|
91
111
|
warningData.push({
|
|
92
112
|
segment: 'headers',
|
|
@@ -97,7 +117,7 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
|
|
|
97
117
|
|
|
98
118
|
// Raw request body
|
|
99
119
|
try {
|
|
100
|
-
const parsedBody = parseRequestRawBody(hookNodes.useRequestRawBody)
|
|
120
|
+
const parsedBody = time('useRequestRawBody', () => parseRequestRawBody(hookNodes.useRequestRawBody))
|
|
101
121
|
if (parsedBody) {
|
|
102
122
|
endpointData.rawBody = parsedBody
|
|
103
123
|
}
|
|
@@ -111,7 +131,9 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
|
|
|
111
131
|
|
|
112
132
|
// Object request body
|
|
113
133
|
try {
|
|
114
|
-
endpointData.objectBody =
|
|
134
|
+
endpointData.objectBody = time('useRequestBody', () =>
|
|
135
|
+
parseRequestObjectInput(hookNodes.useRequestBody, 'useRequestBody'),
|
|
136
|
+
)
|
|
115
137
|
} catch (err) {
|
|
116
138
|
warningData.push({
|
|
117
139
|
segment: 'objectBody',
|
|
@@ -122,7 +144,7 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
|
|
|
122
144
|
|
|
123
145
|
// Request response
|
|
124
146
|
try {
|
|
125
|
-
endpointData.responses = parseRequestResponse(node)
|
|
147
|
+
endpointData.responses = time('response', () => parseRequestResponse(node))
|
|
126
148
|
} catch (err) {
|
|
127
149
|
warningData.push({
|
|
128
150
|
segment: 'response',
|
|
@@ -131,7 +153,7 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
|
|
|
131
153
|
Logger.error('Error', err)
|
|
132
154
|
}
|
|
133
155
|
|
|
134
|
-
return endpointData
|
|
156
|
+
return { endpoint: endpointData, sectionTimings }
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
type HookName =
|
|
@@ -11,6 +11,7 @@ export const TestCase = {
|
|
|
11
11
|
parsesZodQueryNumberArray: 'parses-zod-query-number-array',
|
|
12
12
|
parsesZodQueryObjectArray: 'parses-zod-query-object-array',
|
|
13
13
|
parsesZodQueryOptionalArray: 'parses-zod-query-optional-array',
|
|
14
|
+
parsesZodPipe: 'parses-zod-pipe',
|
|
14
15
|
parsesReturnRecordStringUnknown: 'parses-return-record-string-unknown',
|
|
15
16
|
parsesReturnObjectWithRecordProperty: 'parses-return-object-with-record-property',
|
|
16
17
|
parsesBufferReturnedFromFunction: 'parses-buffer-returned-from-function',
|
|
@@ -23,7 +23,7 @@ describe('OpenApi Analyzer', () => {
|
|
|
23
23
|
},
|
|
24
24
|
[`/test/${id}`],
|
|
25
25
|
)
|
|
26
|
-
const endpoint = analysisResult.find((endpoint) => endpoint.path.startsWith(`/test/${id}`))
|
|
26
|
+
const endpoint = analysisResult.endpoints.find((endpoint) => endpoint.path.startsWith(`/test/${id}`))
|
|
27
27
|
if (!endpoint) {
|
|
28
28
|
throw new Error(`No endpoint with id ${id} found!`)
|
|
29
29
|
}
|
|
@@ -39,7 +39,7 @@ describe('OpenApi Analyzer', () => {
|
|
|
39
39
|
},
|
|
40
40
|
[`/test/${id}`],
|
|
41
41
|
)
|
|
42
|
-
const endpoints = analysisResult.filter((endpoint) => endpoint.path.startsWith(`/test/${id}`))
|
|
42
|
+
const endpoints = analysisResult.endpoints.filter((endpoint) => endpoint.path.startsWith(`/test/${id}`))
|
|
43
43
|
if (endpoints.length === 0) {
|
|
44
44
|
throw new Error(`No endpoint with id ${id} found!`)
|
|
45
45
|
}
|
|
@@ -147,3 +147,9 @@ router.get(`/test/${TestCase.parsesZodQueryOptionalArray}`, (ctx) => {
|
|
|
147
147
|
tags: z.array(z.string()).optional(),
|
|
148
148
|
})
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
router.post(`/test/${TestCase.parsesZodPipe}`, (ctx) => {
|
|
152
|
+
useRequestBody(ctx, {
|
|
153
|
+
value: z.string().pipe(z.coerce.number()),
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -19,7 +19,7 @@ describe('OpenApi Analyzer (Zod Validator)', () => {
|
|
|
19
19
|
},
|
|
20
20
|
[`/test/${id}`],
|
|
21
21
|
)
|
|
22
|
-
const endpoint = analysisResult.find((endpoint) => endpoint.path.startsWith(`/test/${id}`))
|
|
22
|
+
const endpoint = analysisResult.endpoints.find((endpoint) => endpoint.path.startsWith(`/test/${id}`))
|
|
23
23
|
if (!endpoint) {
|
|
24
24
|
throw new Error(`No endpoint with id ${id} found!`)
|
|
25
25
|
}
|
|
@@ -315,6 +315,14 @@ describe('OpenApi Analyzer (Zod Validator)', () => {
|
|
|
315
315
|
])
|
|
316
316
|
expect(endpoint.requestQuery[0].optional).toEqual(true)
|
|
317
317
|
})
|
|
318
|
+
|
|
319
|
+
it('parses zod pipe validators', () => {
|
|
320
|
+
const endpoint = analyzeEndpointById(TestCase.parsesZodPipe)
|
|
321
|
+
|
|
322
|
+
expect(endpoint.objectBody[0].identifier).toEqual('value')
|
|
323
|
+
expect(endpoint.objectBody[0].signature).toEqual('number')
|
|
324
|
+
expect(endpoint.objectBody[0].optional).toEqual(false)
|
|
325
|
+
})
|
|
318
326
|
})
|
|
319
327
|
})
|
|
320
328
|
})
|