@tellescope/schema 1.238.0 → 1.239.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/lib/cjs/generate-openapi.d.ts +106 -0
- package/lib/cjs/generate-openapi.d.ts.map +1 -0
- package/lib/cjs/generate-openapi.js +1148 -0
- package/lib/cjs/generate-openapi.js.map +1 -0
- package/lib/cjs/schema.d.ts +2 -0
- package/lib/cjs/schema.d.ts.map +1 -1
- package/lib/cjs/schema.js +6 -4
- package/lib/cjs/schema.js.map +1 -1
- package/lib/esm/generate-openapi.d.ts +106 -0
- package/lib/esm/generate-openapi.d.ts.map +1 -0
- package/lib/esm/generate-openapi.js +1123 -0
- package/lib/esm/generate-openapi.js.map +1 -0
- package/lib/esm/schema.d.ts +2 -0
- package/lib/esm/schema.d.ts.map +1 -1
- package/lib/esm/schema.js +7 -5
- package/lib/esm/schema.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/openapi.json +139944 -0
- package/package.json +9 -9
- package/src/generate-openapi.ts +1268 -0
- package/src/schema.ts +12 -4
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
#!/usr/bin/env npx ts-node
|
|
2
|
+
/**
|
|
3
|
+
* OpenAPI 3.0 Specification Generator for Tellescope API
|
|
4
|
+
*
|
|
5
|
+
* This script generates an OpenAPI 3.0 JSON specification from the Tellescope schema.
|
|
6
|
+
* It reads the schema definitions and produces a complete API documentation.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* cd packages/public/schema
|
|
10
|
+
* npx ts-node src/generate-openapi.ts [output-path]
|
|
11
|
+
*
|
|
12
|
+
* Default output: ./openapi.json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from 'fs'
|
|
16
|
+
import * as path from 'path'
|
|
17
|
+
import { schema, Model, ModelFields, ModelFieldInfo, CustomAction } from './schema'
|
|
18
|
+
import { ValidatorDefinition } from '@tellescope/validation'
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// OpenAPI Type Definitions
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
interface OpenAPISchemaType {
|
|
25
|
+
type?: string
|
|
26
|
+
format?: string
|
|
27
|
+
items?: OpenAPISchemaType
|
|
28
|
+
properties?: Record<string, OpenAPISchemaType>
|
|
29
|
+
additionalProperties?: OpenAPISchemaType | boolean
|
|
30
|
+
$ref?: string
|
|
31
|
+
oneOf?: OpenAPISchemaType[]
|
|
32
|
+
anyOf?: OpenAPISchemaType[]
|
|
33
|
+
example?: any
|
|
34
|
+
required?: string[]
|
|
35
|
+
nullable?: boolean
|
|
36
|
+
description?: string
|
|
37
|
+
enum?: string[]
|
|
38
|
+
pattern?: string
|
|
39
|
+
minimum?: number
|
|
40
|
+
maximum?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface OpenAPIParameter {
|
|
44
|
+
name: string
|
|
45
|
+
in: 'path' | 'query' | 'header' | 'cookie'
|
|
46
|
+
required?: boolean
|
|
47
|
+
schema: OpenAPISchemaType
|
|
48
|
+
description?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface OpenAPIRequestBody {
|
|
52
|
+
required?: boolean
|
|
53
|
+
content: {
|
|
54
|
+
'application/json': {
|
|
55
|
+
schema: OpenAPISchemaType
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface OpenAPIResponse {
|
|
61
|
+
description: string
|
|
62
|
+
content?: {
|
|
63
|
+
'application/json': {
|
|
64
|
+
schema: OpenAPISchemaType
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface OpenAPIOperation {
|
|
70
|
+
summary?: string
|
|
71
|
+
description?: string
|
|
72
|
+
tags?: string[]
|
|
73
|
+
operationId?: string
|
|
74
|
+
security?: Array<Record<string, string[]>>
|
|
75
|
+
parameters?: OpenAPIParameter[]
|
|
76
|
+
requestBody?: OpenAPIRequestBody
|
|
77
|
+
responses: Record<string, OpenAPIResponse | { $ref: string }>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface OpenAPIPathItem {
|
|
81
|
+
get?: OpenAPIOperation
|
|
82
|
+
post?: OpenAPIOperation
|
|
83
|
+
patch?: OpenAPIOperation
|
|
84
|
+
put?: OpenAPIOperation
|
|
85
|
+
delete?: OpenAPIOperation
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface OpenAPISpec {
|
|
89
|
+
openapi: string
|
|
90
|
+
info: {
|
|
91
|
+
title: string
|
|
92
|
+
version: string
|
|
93
|
+
description: string
|
|
94
|
+
contact?: { email?: string; url?: string }
|
|
95
|
+
}
|
|
96
|
+
servers: Array<{ url: string; description: string }>
|
|
97
|
+
paths: Record<string, OpenAPIPathItem>
|
|
98
|
+
components: {
|
|
99
|
+
schemas: Record<string, OpenAPISchemaType>
|
|
100
|
+
securitySchemes: Record<string, any>
|
|
101
|
+
responses: Record<string, OpenAPIResponse>
|
|
102
|
+
}
|
|
103
|
+
tags: Array<{ name: string; description?: string }>
|
|
104
|
+
security: Array<Record<string, string[]>>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// Helper Functions
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convert a model name to PascalCase
|
|
113
|
+
*/
|
|
114
|
+
function pascalCase(str: string): string {
|
|
115
|
+
return str
|
|
116
|
+
.split('_')
|
|
117
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
118
|
+
.join('')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Convert underscores to hyphens for URL-safe paths (matching url_safe_path from utilities)
|
|
123
|
+
*/
|
|
124
|
+
function urlSafePath(p: string): string {
|
|
125
|
+
return p.replace(/_/g, '-')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the singular form of a model name for URL paths
|
|
130
|
+
*/
|
|
131
|
+
function getSingularName(modelName: string): string {
|
|
132
|
+
const safeName = urlSafePath(modelName)
|
|
133
|
+
// Remove trailing 's' for singular
|
|
134
|
+
return safeName.endsWith('s') ? safeName.slice(0, -1) : safeName
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get the plural form of a model name for URL paths
|
|
139
|
+
*/
|
|
140
|
+
function getPluralName(modelName: string): string {
|
|
141
|
+
return urlSafePath(modelName)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// Validator to OpenAPI Type Mapping
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Convert a Tellescope validator to an OpenAPI schema type
|
|
150
|
+
*/
|
|
151
|
+
function validatorToOpenAPIType(validator: ValidatorDefinition<any>): OpenAPISchemaType {
|
|
152
|
+
try {
|
|
153
|
+
const typeInfo = validator.getType()
|
|
154
|
+
const example = validator.getExample()
|
|
155
|
+
|
|
156
|
+
// Handle primitive string types
|
|
157
|
+
if (typeInfo === 'string') {
|
|
158
|
+
return { type: 'string', example: typeof example === 'string' ? example : undefined }
|
|
159
|
+
}
|
|
160
|
+
if (typeInfo === 'number') {
|
|
161
|
+
return { type: 'number', example: typeof example === 'number' ? example : undefined }
|
|
162
|
+
}
|
|
163
|
+
if (typeInfo === 'boolean') {
|
|
164
|
+
return { type: 'boolean', example: typeof example === 'boolean' ? example : undefined }
|
|
165
|
+
}
|
|
166
|
+
if (typeInfo === 'Date') {
|
|
167
|
+
return { type: 'string', format: 'date-time', example: typeof example === 'string' ? example : undefined }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Handle arrays - getType() returns [innerType] or [example]
|
|
171
|
+
if (Array.isArray(typeInfo)) {
|
|
172
|
+
const innerExample = typeInfo[0]
|
|
173
|
+
|
|
174
|
+
// Check if it's a primitive array
|
|
175
|
+
if (typeof innerExample === 'string') {
|
|
176
|
+
// It's an array of strings
|
|
177
|
+
return {
|
|
178
|
+
type: 'array',
|
|
179
|
+
items: { type: 'string' },
|
|
180
|
+
example: Array.isArray(example) ? example : undefined
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (typeof innerExample === 'number') {
|
|
184
|
+
return {
|
|
185
|
+
type: 'array',
|
|
186
|
+
items: { type: 'number' },
|
|
187
|
+
example: Array.isArray(example) ? example : undefined
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (typeof innerExample === 'boolean') {
|
|
191
|
+
return {
|
|
192
|
+
type: 'array',
|
|
193
|
+
items: { type: 'boolean' },
|
|
194
|
+
example: Array.isArray(example) ? example : undefined
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (typeof innerExample === 'object' && innerExample !== null) {
|
|
198
|
+
return {
|
|
199
|
+
type: 'array',
|
|
200
|
+
items: objectTypeToSchema(innerExample),
|
|
201
|
+
example: Array.isArray(example) ? example : undefined
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fallback for arrays
|
|
206
|
+
return {
|
|
207
|
+
type: 'array',
|
|
208
|
+
items: { type: 'string' },
|
|
209
|
+
example: Array.isArray(example) ? example : undefined
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Handle objects - getType() returns { field: type, ... }
|
|
214
|
+
if (typeof typeInfo === 'object' && typeInfo !== null) {
|
|
215
|
+
return objectTypeToSchema(typeInfo, example)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Fallback for unknown types
|
|
219
|
+
return { type: 'object', additionalProperties: true }
|
|
220
|
+
} catch (e) {
|
|
221
|
+
// If validator doesn't have getType/getExample, return generic type
|
|
222
|
+
return { type: 'object', additionalProperties: true }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Convert an object type definition to OpenAPI schema
|
|
228
|
+
*/
|
|
229
|
+
function objectTypeToSchema(typeObj: Record<string, any>, example?: any): OpenAPISchemaType {
|
|
230
|
+
const properties: Record<string, OpenAPISchemaType> = {}
|
|
231
|
+
|
|
232
|
+
for (const [key, value] of Object.entries(typeObj)) {
|
|
233
|
+
if (typeof value === 'string') {
|
|
234
|
+
// Direct type string
|
|
235
|
+
if (value === 'string') {
|
|
236
|
+
properties[key] = { type: 'string' }
|
|
237
|
+
} else if (value === 'number') {
|
|
238
|
+
properties[key] = { type: 'number' }
|
|
239
|
+
} else if (value === 'boolean') {
|
|
240
|
+
properties[key] = { type: 'boolean' }
|
|
241
|
+
} else if (value === 'Date') {
|
|
242
|
+
properties[key] = { type: 'string', format: 'date-time' }
|
|
243
|
+
} else {
|
|
244
|
+
// Treat as string enum value or literal
|
|
245
|
+
properties[key] = { type: 'string' }
|
|
246
|
+
}
|
|
247
|
+
} else if (typeof value === 'number') {
|
|
248
|
+
properties[key] = { type: 'number', example: value }
|
|
249
|
+
} else if (typeof value === 'boolean') {
|
|
250
|
+
properties[key] = { type: 'boolean', example: value }
|
|
251
|
+
} else if (Array.isArray(value)) {
|
|
252
|
+
// Array type
|
|
253
|
+
const innerType = value[0]
|
|
254
|
+
if (typeof innerType === 'string') {
|
|
255
|
+
properties[key] = { type: 'array', items: { type: 'string' } }
|
|
256
|
+
} else if (typeof innerType === 'number') {
|
|
257
|
+
properties[key] = { type: 'array', items: { type: 'number' } }
|
|
258
|
+
} else if (typeof innerType === 'object' && innerType !== null) {
|
|
259
|
+
properties[key] = { type: 'array', items: objectTypeToSchema(innerType) }
|
|
260
|
+
} else {
|
|
261
|
+
properties[key] = { type: 'array', items: { type: 'string' } }
|
|
262
|
+
}
|
|
263
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
264
|
+
// Nested object
|
|
265
|
+
properties[key] = objectTypeToSchema(value)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties,
|
|
272
|
+
example: typeof example === 'object' ? example : undefined
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ============================================================================
|
|
277
|
+
// Schema Component Generators
|
|
278
|
+
// ============================================================================
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Generate the full model schema for responses (includes all fields except redacted)
|
|
282
|
+
*/
|
|
283
|
+
function generateModelSchema(modelName: string, fields: ModelFields<any>): OpenAPISchemaType {
|
|
284
|
+
const properties: Record<string, OpenAPISchemaType> = {}
|
|
285
|
+
const required: string[] = []
|
|
286
|
+
|
|
287
|
+
// Add id field
|
|
288
|
+
properties['id'] = {
|
|
289
|
+
type: 'string',
|
|
290
|
+
description: 'Unique identifier',
|
|
291
|
+
pattern: '^[0-9a-fA-F]{24}$'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const [fieldName, fieldInfo] of Object.entries(fields)) {
|
|
295
|
+
const info = fieldInfo as ModelFieldInfo<any, any>
|
|
296
|
+
|
|
297
|
+
// Skip fields redacted for all users
|
|
298
|
+
if (info.redactions?.includes('all')) continue
|
|
299
|
+
|
|
300
|
+
// Skip internal _id field (we use 'id' instead)
|
|
301
|
+
if (fieldName === '_id') continue
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const schema = validatorToOpenAPIType(info.validator)
|
|
305
|
+
|
|
306
|
+
// Add description for redacted fields
|
|
307
|
+
if (info.redactions?.includes('enduser')) {
|
|
308
|
+
schema.description = 'Not visible to endusers'
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
properties[fieldName] = schema
|
|
312
|
+
|
|
313
|
+
if (info.required) {
|
|
314
|
+
required.push(fieldName)
|
|
315
|
+
}
|
|
316
|
+
} catch (e) {
|
|
317
|
+
// Skip fields with invalid validators
|
|
318
|
+
properties[fieldName] = { type: 'object', additionalProperties: true }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
type: 'object',
|
|
324
|
+
properties,
|
|
325
|
+
required: required.length > 0 ? required : undefined
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Generate schema for create operations (excludes readonly fields)
|
|
331
|
+
*/
|
|
332
|
+
function generateCreateSchema(modelName: string, fields: ModelFields<any>): OpenAPISchemaType {
|
|
333
|
+
const properties: Record<string, OpenAPISchemaType> = {}
|
|
334
|
+
const required: string[] = []
|
|
335
|
+
|
|
336
|
+
for (const [fieldName, fieldInfo] of Object.entries(fields)) {
|
|
337
|
+
const info = fieldInfo as ModelFieldInfo<any, any>
|
|
338
|
+
|
|
339
|
+
// Skip readonly fields for create
|
|
340
|
+
if (info.readonly) continue
|
|
341
|
+
|
|
342
|
+
// Skip fields redacted for all users
|
|
343
|
+
if (info.redactions?.includes('all')) continue
|
|
344
|
+
|
|
345
|
+
// Skip internal fields
|
|
346
|
+
if (fieldName === '_id') continue
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
properties[fieldName] = validatorToOpenAPIType(info.validator)
|
|
350
|
+
|
|
351
|
+
if (info.required) {
|
|
352
|
+
required.push(fieldName)
|
|
353
|
+
}
|
|
354
|
+
} catch (e) {
|
|
355
|
+
properties[fieldName] = { type: 'object', additionalProperties: true }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
type: 'object',
|
|
361
|
+
properties,
|
|
362
|
+
required: required.length > 0 ? required : undefined
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Generate schema for update operations (excludes readonly and updatesDisabled fields)
|
|
368
|
+
*/
|
|
369
|
+
function generateUpdateSchema(modelName: string, fields: ModelFields<any>): OpenAPISchemaType {
|
|
370
|
+
const properties: Record<string, OpenAPISchemaType> = {}
|
|
371
|
+
|
|
372
|
+
for (const [fieldName, fieldInfo] of Object.entries(fields)) {
|
|
373
|
+
const info = fieldInfo as ModelFieldInfo<any, any>
|
|
374
|
+
|
|
375
|
+
// Skip readonly fields
|
|
376
|
+
if (info.readonly) continue
|
|
377
|
+
|
|
378
|
+
// Skip fields where updates are disabled
|
|
379
|
+
if (info.updatesDisabled) continue
|
|
380
|
+
|
|
381
|
+
// Skip fields redacted for all users
|
|
382
|
+
if (info.redactions?.includes('all')) continue
|
|
383
|
+
|
|
384
|
+
// Skip internal fields
|
|
385
|
+
if (fieldName === '_id') continue
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
properties[fieldName] = validatorToOpenAPIType(info.validator)
|
|
389
|
+
} catch (e) {
|
|
390
|
+
properties[fieldName] = { type: 'object', additionalProperties: true }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
type: 'object',
|
|
396
|
+
properties,
|
|
397
|
+
description: 'Fields to update (all optional)'
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Generate all schema components
|
|
403
|
+
*/
|
|
404
|
+
function generateComponents(): Record<string, OpenAPISchemaType> {
|
|
405
|
+
const schemas: Record<string, OpenAPISchemaType> = {}
|
|
406
|
+
|
|
407
|
+
for (const [modelName, modelDef] of Object.entries(schema)) {
|
|
408
|
+
const model = modelDef as Model<any, any>
|
|
409
|
+
const pascalName = pascalCase(modelName)
|
|
410
|
+
|
|
411
|
+
// Generate full model schema (for responses)
|
|
412
|
+
schemas[pascalName] = generateModelSchema(modelName, model.fields)
|
|
413
|
+
|
|
414
|
+
// Generate create schema
|
|
415
|
+
schemas[`${pascalName}Create`] = generateCreateSchema(modelName, model.fields)
|
|
416
|
+
|
|
417
|
+
// Generate update schema
|
|
418
|
+
schemas[`${pascalName}Update`] = generateUpdateSchema(modelName, model.fields)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Add common schemas
|
|
422
|
+
schemas['Error'] = {
|
|
423
|
+
type: 'object',
|
|
424
|
+
properties: {
|
|
425
|
+
message: { type: 'string', description: 'Error message' },
|
|
426
|
+
code: { type: 'integer', description: 'Error code' },
|
|
427
|
+
info: { type: 'object', additionalProperties: true, description: 'Additional error information' }
|
|
428
|
+
},
|
|
429
|
+
required: ['message']
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
schemas['ObjectId'] = {
|
|
433
|
+
type: 'string',
|
|
434
|
+
pattern: '^[0-9a-fA-F]{24}$',
|
|
435
|
+
description: 'MongoDB ObjectId',
|
|
436
|
+
example: '60398b0231a295e64f084fd9'
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return schemas
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ============================================================================
|
|
443
|
+
// Path Generators
|
|
444
|
+
// ============================================================================
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get common query parameters for readMany operations
|
|
448
|
+
*/
|
|
449
|
+
function getReadManyParameters(): OpenAPIParameter[] {
|
|
450
|
+
return [
|
|
451
|
+
{
|
|
452
|
+
name: 'limit',
|
|
453
|
+
in: 'query',
|
|
454
|
+
schema: { type: 'integer', minimum: 1, maximum: 1000 },
|
|
455
|
+
description: 'Maximum number of records to return (default varies by model, max 1000)'
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: 'lastId',
|
|
459
|
+
in: 'query',
|
|
460
|
+
schema: { type: 'string', pattern: '^[0-9a-fA-F]{24}$' },
|
|
461
|
+
description: 'Cursor for pagination - ID of the last record from previous page'
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
name: 'sort',
|
|
465
|
+
in: 'query',
|
|
466
|
+
schema: { type: 'string', enum: ['oldFirst', 'newFirst'] },
|
|
467
|
+
description: 'Sort order by creation date'
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: 'sortBy',
|
|
471
|
+
in: 'query',
|
|
472
|
+
schema: { type: 'string' },
|
|
473
|
+
description: 'Field to sort by'
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
name: 'mdbFilter',
|
|
477
|
+
in: 'query',
|
|
478
|
+
schema: { type: 'string' },
|
|
479
|
+
description: 'JSON-encoded MongoDB-style filter object'
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: 'search',
|
|
483
|
+
in: 'query',
|
|
484
|
+
schema: { type: 'string' },
|
|
485
|
+
description: 'Text search query'
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: 'from',
|
|
489
|
+
in: 'query',
|
|
490
|
+
schema: { type: 'string', format: 'date-time' },
|
|
491
|
+
description: 'Filter records created after this date'
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
name: 'to',
|
|
495
|
+
in: 'query',
|
|
496
|
+
schema: { type: 'string', format: 'date-time' },
|
|
497
|
+
description: 'Filter records created before this date'
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
name: 'fromToField',
|
|
501
|
+
in: 'query',
|
|
502
|
+
schema: { type: 'string' },
|
|
503
|
+
description: 'Field to use for date range filtering (default: createdAt)'
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: 'ids',
|
|
507
|
+
in: 'query',
|
|
508
|
+
schema: { type: 'string' },
|
|
509
|
+
description: 'Comma-separated list of IDs to filter by'
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: 'returnCount',
|
|
513
|
+
in: 'query',
|
|
514
|
+
schema: { type: 'boolean' },
|
|
515
|
+
description: 'If true, return only the count of matching records'
|
|
516
|
+
}
|
|
517
|
+
]
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Generate default CRUD operation paths for a model
|
|
522
|
+
*/
|
|
523
|
+
function generateDefaultOperationPaths(
|
|
524
|
+
modelName: string,
|
|
525
|
+
model: Model<any, any>
|
|
526
|
+
): Record<string, OpenAPIPathItem> {
|
|
527
|
+
const paths: Record<string, OpenAPIPathItem> = {}
|
|
528
|
+
const singular = getSingularName(modelName)
|
|
529
|
+
const plural = getPluralName(modelName)
|
|
530
|
+
const pascalName = pascalCase(modelName)
|
|
531
|
+
const defaultActions = model.defaultActions || {}
|
|
532
|
+
const description = model.info?.description || `${pascalName} resource`
|
|
533
|
+
|
|
534
|
+
// CREATE: POST /v1/{singular}
|
|
535
|
+
if (defaultActions.create !== undefined) {
|
|
536
|
+
const pathKey = `/v1/${singular}`
|
|
537
|
+
paths[pathKey] = paths[pathKey] || {}
|
|
538
|
+
paths[pathKey].post = {
|
|
539
|
+
summary: `Create ${singular}`,
|
|
540
|
+
description: `Creates a new ${singular}. ${description}`,
|
|
541
|
+
tags: [pascalName],
|
|
542
|
+
operationId: `create${pascalName}`,
|
|
543
|
+
security: [{ bearerAuth: [] }, { apiKey: [] }],
|
|
544
|
+
requestBody: {
|
|
545
|
+
required: true,
|
|
546
|
+
content: {
|
|
547
|
+
'application/json': {
|
|
548
|
+
schema: { $ref: `#/components/schemas/${pascalName}Create` }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
responses: {
|
|
553
|
+
'200': {
|
|
554
|
+
description: `Created ${singular}`,
|
|
555
|
+
content: {
|
|
556
|
+
'application/json': {
|
|
557
|
+
schema: { $ref: `#/components/schemas/${pascalName}` }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
'400': { $ref: '#/components/responses/BadRequest' },
|
|
562
|
+
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// CREATE_MANY: POST /v1/{plural}
|
|
568
|
+
if (defaultActions.createMany !== undefined) {
|
|
569
|
+
const pathKey = `/v1/${plural}`
|
|
570
|
+
paths[pathKey] = paths[pathKey] || {}
|
|
571
|
+
paths[pathKey].post = {
|
|
572
|
+
summary: `Create multiple ${plural}`,
|
|
573
|
+
description: `Creates multiple ${plural} in a single request`,
|
|
574
|
+
tags: [pascalName],
|
|
575
|
+
operationId: `createMany${pascalName}`,
|
|
576
|
+
security: [{ bearerAuth: [] }, { apiKey: [] }],
|
|
577
|
+
requestBody: {
|
|
578
|
+
required: true,
|
|
579
|
+
content: {
|
|
580
|
+
'application/json': {
|
|
581
|
+
schema: {
|
|
582
|
+
type: 'object',
|
|
583
|
+
properties: {
|
|
584
|
+
create: {
|
|
585
|
+
type: 'array',
|
|
586
|
+
items: { $ref: `#/components/schemas/${pascalName}Create` },
|
|
587
|
+
description: 'Array of records to create'
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
required: ['create']
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
responses: {
|
|
596
|
+
'200': {
|
|
597
|
+
description: `Created ${plural}`,
|
|
598
|
+
content: {
|
|
599
|
+
'application/json': {
|
|
600
|
+
schema: {
|
|
601
|
+
type: 'object',
|
|
602
|
+
properties: {
|
|
603
|
+
created: {
|
|
604
|
+
type: 'array',
|
|
605
|
+
items: { $ref: `#/components/schemas/${pascalName}` }
|
|
606
|
+
},
|
|
607
|
+
errors: {
|
|
608
|
+
type: 'array',
|
|
609
|
+
items: {
|
|
610
|
+
type: 'object',
|
|
611
|
+
properties: {
|
|
612
|
+
index: { type: 'integer' },
|
|
613
|
+
error: { type: 'string' }
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
'400': { $ref: '#/components/responses/BadRequest' },
|
|
623
|
+
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// READ by ID: GET /v1/{singular}/{id}
|
|
629
|
+
if (defaultActions.read !== undefined) {
|
|
630
|
+
const pathKey = `/v1/${singular}/{id}`
|
|
631
|
+
paths[pathKey] = paths[pathKey] || {}
|
|
632
|
+
paths[pathKey].get = {
|
|
633
|
+
summary: `Get ${singular} by ID`,
|
|
634
|
+
description: `Retrieves a single ${singular} by its ID`,
|
|
635
|
+
tags: [pascalName],
|
|
636
|
+
operationId: `get${pascalName}ById`,
|
|
637
|
+
security: [{ bearerAuth: [] }, { apiKey: [] }],
|
|
638
|
+
parameters: [
|
|
639
|
+
{
|
|
640
|
+
name: 'id',
|
|
641
|
+
in: 'path',
|
|
642
|
+
required: true,
|
|
643
|
+
schema: { type: 'string', pattern: '^[0-9a-fA-F]{24}$' },
|
|
644
|
+
description: 'The unique identifier of the record'
|
|
645
|
+
}
|
|
646
|
+
],
|
|
647
|
+
responses: {
|
|
648
|
+
'200': {
|
|
649
|
+
description: `${pascalName} record`,
|
|
650
|
+
content: {
|
|
651
|
+
'application/json': {
|
|
652
|
+
schema: { $ref: `#/components/schemas/${pascalName}` }
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
'401': { $ref: '#/components/responses/Unauthorized' },
|
|
657
|
+
'404': { $ref: '#/components/responses/NotFound' }
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// READ_MANY: GET /v1/{plural}
|
|
663
|
+
if (defaultActions.readMany !== undefined) {
|
|
664
|
+
const pathKey = `/v1/${plural}`
|
|
665
|
+
paths[pathKey] = paths[pathKey] || {}
|
|
666
|
+
paths[pathKey].get = {
|
|
667
|
+
summary: `List ${plural}`,
|
|
668
|
+
description: `Retrieves a list of ${plural} with optional filtering and pagination`,
|
|
669
|
+
tags: [pascalName],
|
|
670
|
+
operationId: `list${pascalName}`,
|
|
671
|
+
security: [{ bearerAuth: [] }, { apiKey: [] }],
|
|
672
|
+
parameters: getReadManyParameters(),
|
|
673
|
+
responses: {
|
|
674
|
+
'200': {
|
|
675
|
+
description: `List of ${plural}`,
|
|
676
|
+
content: {
|
|
677
|
+
'application/json': {
|
|
678
|
+
schema: {
|
|
679
|
+
oneOf: [
|
|
680
|
+
{
|
|
681
|
+
type: 'array',
|
|
682
|
+
items: { $ref: `#/components/schemas/${pascalName}` }
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
type: 'object',
|
|
686
|
+
properties: {
|
|
687
|
+
count: { type: 'integer', description: 'Total count when returnCount=true' }
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
]
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// UPDATE: PATCH /v1/{singular}/{id}
|
|
701
|
+
if (defaultActions.update !== undefined) {
|
|
702
|
+
const pathKey = `/v1/${singular}/{id}`
|
|
703
|
+
paths[pathKey] = paths[pathKey] || {}
|
|
704
|
+
paths[pathKey].patch = {
|
|
705
|
+
summary: `Update ${singular}`,
|
|
706
|
+
description: `Updates a ${singular} by ID`,
|
|
707
|
+
tags: [pascalName],
|
|
708
|
+
operationId: `update${pascalName}`,
|
|
709
|
+
security: [{ bearerAuth: [] }, { apiKey: [] }],
|
|
710
|
+
parameters: [
|
|
711
|
+
{
|
|
712
|
+
name: 'id',
|
|
713
|
+
in: 'path',
|
|
714
|
+
required: true,
|
|
715
|
+
schema: { type: 'string', pattern: '^[0-9a-fA-F]{24}$' },
|
|
716
|
+
description: 'The unique identifier of the record to update'
|
|
717
|
+
}
|
|
718
|
+
],
|
|
719
|
+
requestBody: {
|
|
720
|
+
required: true,
|
|
721
|
+
content: {
|
|
722
|
+
'application/json': {
|
|
723
|
+
schema: {
|
|
724
|
+
type: 'object',
|
|
725
|
+
properties: {
|
|
726
|
+
updates: { $ref: `#/components/schemas/${pascalName}Update` },
|
|
727
|
+
options: {
|
|
728
|
+
type: 'object',
|
|
729
|
+
properties: {
|
|
730
|
+
replaceObjectFields: {
|
|
731
|
+
type: 'boolean',
|
|
732
|
+
description: 'If true, replace object fields entirely instead of merging'
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
required: ['updates']
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
responses: {
|
|
743
|
+
'200': {
|
|
744
|
+
description: `Updated ${singular}`,
|
|
745
|
+
content: {
|
|
746
|
+
'application/json': {
|
|
747
|
+
schema: { $ref: `#/components/schemas/${pascalName}` }
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
'400': { $ref: '#/components/responses/BadRequest' },
|
|
752
|
+
'401': { $ref: '#/components/responses/Unauthorized' },
|
|
753
|
+
'404': { $ref: '#/components/responses/NotFound' }
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// DELETE: DELETE /v1/{singular}/{id}
|
|
759
|
+
if (defaultActions.delete !== undefined) {
|
|
760
|
+
const pathKey = `/v1/${singular}/{id}`
|
|
761
|
+
paths[pathKey] = paths[pathKey] || {}
|
|
762
|
+
paths[pathKey].delete = {
|
|
763
|
+
summary: `Delete ${singular}`,
|
|
764
|
+
description: `Deletes a ${singular} by ID`,
|
|
765
|
+
tags: [pascalName],
|
|
766
|
+
operationId: `delete${pascalName}`,
|
|
767
|
+
security: [{ bearerAuth: [] }, { apiKey: [] }],
|
|
768
|
+
parameters: [
|
|
769
|
+
{
|
|
770
|
+
name: 'id',
|
|
771
|
+
in: 'path',
|
|
772
|
+
required: true,
|
|
773
|
+
schema: { type: 'string', pattern: '^[0-9a-fA-F]{24}$' },
|
|
774
|
+
description: 'The unique identifier of the record to delete'
|
|
775
|
+
}
|
|
776
|
+
],
|
|
777
|
+
responses: {
|
|
778
|
+
'204': { description: 'Successfully deleted' },
|
|
779
|
+
'401': { $ref: '#/components/responses/Unauthorized' },
|
|
780
|
+
'404': { $ref: '#/components/responses/NotFound' }
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return paths
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Map CRUD access type to default HTTP method
|
|
790
|
+
*/
|
|
791
|
+
function getDefaultMethodForAccess(access: string): string {
|
|
792
|
+
switch (access) {
|
|
793
|
+
case 'create': return 'post'
|
|
794
|
+
case 'read': return 'get'
|
|
795
|
+
case 'update': return 'patch'
|
|
796
|
+
case 'delete': return 'delete'
|
|
797
|
+
default: return 'post'
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Generate paths for custom actions
|
|
803
|
+
*/
|
|
804
|
+
function generateCustomActionPaths(
|
|
805
|
+
modelName: string,
|
|
806
|
+
customActions: Record<string, CustomAction>
|
|
807
|
+
): Record<string, OpenAPIPathItem> {
|
|
808
|
+
const paths: Record<string, OpenAPIPathItem> = {}
|
|
809
|
+
const singular = getSingularName(modelName)
|
|
810
|
+
const pascalName = pascalCase(modelName)
|
|
811
|
+
|
|
812
|
+
for (const [actionName, action] of Object.entries(customActions)) {
|
|
813
|
+
// Determine the path
|
|
814
|
+
let actionPath: string
|
|
815
|
+
if (action.path) {
|
|
816
|
+
actionPath = action.path.startsWith('/v1') ? action.path : `/v1${action.path}`
|
|
817
|
+
} else {
|
|
818
|
+
// Generate path from action name
|
|
819
|
+
const safeName = actionName.replace(/_/g, '-')
|
|
820
|
+
actionPath = `/v1/${singular}/${safeName}`
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Determine HTTP method
|
|
824
|
+
const method = (action.method || getDefaultMethodForAccess(action.access)).toLowerCase()
|
|
825
|
+
if (!['get', 'post', 'patch', 'put', 'delete'].includes(method)) continue
|
|
826
|
+
|
|
827
|
+
// Build operation
|
|
828
|
+
const operation: OpenAPIOperation = {
|
|
829
|
+
summary: action.name || actionName.replace(/_/g, ' '),
|
|
830
|
+
description: buildActionDescription(action),
|
|
831
|
+
tags: [pascalName],
|
|
832
|
+
operationId: `${modelName}_${actionName}`,
|
|
833
|
+
security: action.enduserOnly
|
|
834
|
+
? [{ enduserAuth: [] }]
|
|
835
|
+
: [{ bearerAuth: [] }, { apiKey: [] }],
|
|
836
|
+
responses: {}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Add admin-only note
|
|
840
|
+
if (action.adminOnly) {
|
|
841
|
+
operation.description = `**Admin only.** ${operation.description || ''}`
|
|
842
|
+
}
|
|
843
|
+
if (action.rootAdminOnly) {
|
|
844
|
+
operation.description = `**Root admin only.** ${operation.description || ''}`
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Generate parameters
|
|
848
|
+
const { pathParams, queryParams, bodySchema } = generateActionParameters(
|
|
849
|
+
action,
|
|
850
|
+
method,
|
|
851
|
+
actionPath
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
if (pathParams.length > 0 || queryParams.length > 0) {
|
|
855
|
+
operation.parameters = [...pathParams, ...queryParams]
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (bodySchema && ['post', 'patch', 'put'].includes(method)) {
|
|
859
|
+
operation.requestBody = {
|
|
860
|
+
required: true,
|
|
861
|
+
content: {
|
|
862
|
+
'application/json': { schema: bodySchema }
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Generate responses
|
|
868
|
+
operation.responses = generateActionResponses(action, pascalName)
|
|
869
|
+
|
|
870
|
+
// Add to paths
|
|
871
|
+
paths[actionPath] = paths[actionPath] || {}
|
|
872
|
+
;(paths[actionPath] as any)[method] = operation
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return paths
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Build description for a custom action including warnings and notes
|
|
880
|
+
*/
|
|
881
|
+
function buildActionDescription(action: CustomAction): string {
|
|
882
|
+
let description = action.description || ''
|
|
883
|
+
|
|
884
|
+
if (action.warnings && action.warnings.length > 0) {
|
|
885
|
+
description += '\n\n**Warnings:**\n' + action.warnings.map(w => `- ${w}`).join('\n')
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (action.notes && action.notes.length > 0) {
|
|
889
|
+
description += '\n\n**Notes:**\n' + action.notes.map(n => `- ${n}`).join('\n')
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return description.trim()
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Generate parameters for a custom action
|
|
897
|
+
*/
|
|
898
|
+
function generateActionParameters(
|
|
899
|
+
action: CustomAction,
|
|
900
|
+
method: string,
|
|
901
|
+
actionPath: string
|
|
902
|
+
): {
|
|
903
|
+
pathParams: OpenAPIParameter[]
|
|
904
|
+
queryParams: OpenAPIParameter[]
|
|
905
|
+
bodySchema: OpenAPISchemaType | null
|
|
906
|
+
} {
|
|
907
|
+
const pathParams: OpenAPIParameter[] = []
|
|
908
|
+
const queryParams: OpenAPIParameter[] = []
|
|
909
|
+
const bodyProperties: Record<string, OpenAPISchemaType> = {}
|
|
910
|
+
const requiredBody: string[] = []
|
|
911
|
+
|
|
912
|
+
// Extract path parameters from the path
|
|
913
|
+
const pathParamMatches = actionPath.match(/\{(\w+)\}/g) || []
|
|
914
|
+
const pathParamNames = pathParamMatches.map(m => m.slice(1, -1))
|
|
915
|
+
|
|
916
|
+
// Process action parameters
|
|
917
|
+
if (action.parameters) {
|
|
918
|
+
for (const [paramName, paramInfo] of Object.entries(action.parameters)) {
|
|
919
|
+
const info = paramInfo as ModelFieldInfo<any, any>
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const schema = validatorToOpenAPIType(info.validator)
|
|
923
|
+
|
|
924
|
+
if (pathParamNames.includes(paramName)) {
|
|
925
|
+
pathParams.push({
|
|
926
|
+
name: paramName,
|
|
927
|
+
in: 'path',
|
|
928
|
+
required: true,
|
|
929
|
+
schema
|
|
930
|
+
})
|
|
931
|
+
} else if (method === 'get' || method === 'delete') {
|
|
932
|
+
// GET/DELETE use query parameters
|
|
933
|
+
queryParams.push({
|
|
934
|
+
name: paramName,
|
|
935
|
+
in: 'query',
|
|
936
|
+
required: !!info.required,
|
|
937
|
+
schema
|
|
938
|
+
})
|
|
939
|
+
} else {
|
|
940
|
+
// POST/PATCH/PUT use request body
|
|
941
|
+
bodyProperties[paramName] = schema
|
|
942
|
+
if (info.required) {
|
|
943
|
+
requiredBody.push(paramName)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
} catch (e) {
|
|
947
|
+
// Skip parameters with invalid validators
|
|
948
|
+
bodyProperties[paramName] = { type: 'object', additionalProperties: true }
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const bodySchema = Object.keys(bodyProperties).length > 0
|
|
954
|
+
? {
|
|
955
|
+
type: 'object' as const,
|
|
956
|
+
properties: bodyProperties,
|
|
957
|
+
required: requiredBody.length > 0 ? requiredBody : undefined
|
|
958
|
+
}
|
|
959
|
+
: null
|
|
960
|
+
|
|
961
|
+
return { pathParams, queryParams, bodySchema }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Generate response schemas for a custom action
|
|
966
|
+
*/
|
|
967
|
+
function generateActionResponses(
|
|
968
|
+
action: CustomAction,
|
|
969
|
+
pascalName: string
|
|
970
|
+
): Record<string, OpenAPIResponse | { $ref: string }> {
|
|
971
|
+
const responses: Record<string, OpenAPIResponse | { $ref: string }> = {
|
|
972
|
+
'400': { $ref: '#/components/responses/BadRequest' },
|
|
973
|
+
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Handle returns field
|
|
977
|
+
if (!action.returns) {
|
|
978
|
+
responses['200'] = { description: 'Success' }
|
|
979
|
+
return responses
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Handle string reference to a model (e.g., returns: 'meeting')
|
|
983
|
+
if (typeof action.returns === 'string') {
|
|
984
|
+
const modelRef = pascalCase(action.returns)
|
|
985
|
+
responses['200'] = {
|
|
986
|
+
description: 'Success',
|
|
987
|
+
content: {
|
|
988
|
+
'application/json': {
|
|
989
|
+
schema: { $ref: `#/components/schemas/${modelRef}` }
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return responses
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Handle empty object
|
|
997
|
+
if (typeof action.returns === 'object' && Object.keys(action.returns).length === 0) {
|
|
998
|
+
responses['200'] = { description: 'Success' }
|
|
999
|
+
return responses
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Check if returns has a 'validator' property directly (single field return type)
|
|
1003
|
+
if (typeof action.returns === 'object' && 'validator' in action.returns) {
|
|
1004
|
+
const returnInfo = action.returns as ModelFieldInfo<any, any>
|
|
1005
|
+
try {
|
|
1006
|
+
responses['200'] = {
|
|
1007
|
+
description: 'Success',
|
|
1008
|
+
content: {
|
|
1009
|
+
'application/json': {
|
|
1010
|
+
schema: validatorToOpenAPIType(returnInfo.validator)
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
} catch (e) {
|
|
1015
|
+
responses['200'] = { description: 'Success' }
|
|
1016
|
+
}
|
|
1017
|
+
return responses
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Returns is ModelFields (object with multiple fields)
|
|
1021
|
+
const properties: Record<string, OpenAPISchemaType> = {}
|
|
1022
|
+
const required: string[] = []
|
|
1023
|
+
|
|
1024
|
+
for (const [fieldName, fieldInfo] of Object.entries(action.returns)) {
|
|
1025
|
+
// Skip if fieldInfo is not an object with validator
|
|
1026
|
+
if (typeof fieldInfo !== 'object' || fieldInfo === null) continue
|
|
1027
|
+
|
|
1028
|
+
const info = fieldInfo as ModelFieldInfo<any, any>
|
|
1029
|
+
|
|
1030
|
+
// Check if this field has a validator
|
|
1031
|
+
if (!info.validator) {
|
|
1032
|
+
properties[fieldName] = { type: 'object', additionalProperties: true }
|
|
1033
|
+
continue
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
try {
|
|
1037
|
+
properties[fieldName] = validatorToOpenAPIType(info.validator)
|
|
1038
|
+
if (info.required) {
|
|
1039
|
+
required.push(fieldName)
|
|
1040
|
+
}
|
|
1041
|
+
} catch (e) {
|
|
1042
|
+
properties[fieldName] = { type: 'object', additionalProperties: true }
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (Object.keys(properties).length === 0) {
|
|
1047
|
+
responses['200'] = { description: 'Success' }
|
|
1048
|
+
} else {
|
|
1049
|
+
responses['200'] = {
|
|
1050
|
+
description: 'Success',
|
|
1051
|
+
content: {
|
|
1052
|
+
'application/json': {
|
|
1053
|
+
schema: {
|
|
1054
|
+
type: 'object',
|
|
1055
|
+
properties,
|
|
1056
|
+
required: required.length > 0 ? required : undefined
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return responses
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Merge path items, combining operations from multiple sources
|
|
1068
|
+
*/
|
|
1069
|
+
function mergePaths(
|
|
1070
|
+
existing: Record<string, OpenAPIPathItem>,
|
|
1071
|
+
newPaths: Record<string, OpenAPIPathItem>
|
|
1072
|
+
): Record<string, OpenAPIPathItem> {
|
|
1073
|
+
const result = { ...existing }
|
|
1074
|
+
|
|
1075
|
+
for (const [pathKey, methods] of Object.entries(newPaths)) {
|
|
1076
|
+
if (result[pathKey]) {
|
|
1077
|
+
result[pathKey] = { ...result[pathKey], ...methods }
|
|
1078
|
+
} else {
|
|
1079
|
+
result[pathKey] = methods
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return result
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ============================================================================
|
|
1087
|
+
// Security Schemes
|
|
1088
|
+
// ============================================================================
|
|
1089
|
+
|
|
1090
|
+
function generateSecuritySchemes(): Record<string, any> {
|
|
1091
|
+
return {
|
|
1092
|
+
bearerAuth: {
|
|
1093
|
+
type: 'http',
|
|
1094
|
+
scheme: 'bearer',
|
|
1095
|
+
bearerFormat: 'JWT',
|
|
1096
|
+
description: 'JWT token obtained from user login. Pass as Authorization header: Bearer <token>'
|
|
1097
|
+
},
|
|
1098
|
+
apiKey: {
|
|
1099
|
+
type: 'apiKey',
|
|
1100
|
+
in: 'header',
|
|
1101
|
+
name: 'Authorization',
|
|
1102
|
+
description: 'API key for service accounts. Pass as Authorization header with format: API_KEY {your_key}'
|
|
1103
|
+
},
|
|
1104
|
+
enduserAuth: {
|
|
1105
|
+
type: 'http',
|
|
1106
|
+
scheme: 'bearer',
|
|
1107
|
+
bearerFormat: 'JWT',
|
|
1108
|
+
description: 'JWT token for enduser (patient) authentication'
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ============================================================================
|
|
1114
|
+
// Main Generator
|
|
1115
|
+
// ============================================================================
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Generate the complete OpenAPI specification
|
|
1119
|
+
*/
|
|
1120
|
+
function generateOpenAPISpec(): OpenAPISpec {
|
|
1121
|
+
const spec: OpenAPISpec = {
|
|
1122
|
+
openapi: '3.0.3',
|
|
1123
|
+
info: {
|
|
1124
|
+
title: 'Tellescope API',
|
|
1125
|
+
version: '1.0.0',
|
|
1126
|
+
description: `Healthcare platform API for patient management, communications, and automation.
|
|
1127
|
+
|
|
1128
|
+
## Authentication
|
|
1129
|
+
|
|
1130
|
+
The API supports multiple authentication methods:
|
|
1131
|
+
|
|
1132
|
+
- **Bearer Token (JWT)**: Obtain a token via login and pass as \`Authorization: Bearer <token>\`
|
|
1133
|
+
- **API Key**: Pass as header \`Authorization: API_KEY <key>\`
|
|
1134
|
+
|
|
1135
|
+
## Pagination
|
|
1136
|
+
|
|
1137
|
+
List endpoints use cursor-based pagination:
|
|
1138
|
+
- \`limit\`: Maximum records to return (default varies, max 1000)
|
|
1139
|
+
- \`lastId\`: ID of the last record from previous page
|
|
1140
|
+
|
|
1141
|
+
## Filtering
|
|
1142
|
+
|
|
1143
|
+
Use the \`mdbFilter\` query parameter with JSON-encoded MongoDB-style queries:
|
|
1144
|
+
\`\`\`
|
|
1145
|
+
?mdbFilter={"status":"active","priority":{"$in":["high","urgent"]}}
|
|
1146
|
+
\`\`\`
|
|
1147
|
+
|
|
1148
|
+
Supported operators: \`$eq\`, \`$ne\`, \`$gt\`, \`$gte\`, \`$lt\`, \`$lte\`, \`$in\`, \`$nin\`, \`$exists\`, \`$or\`, \`$and\`
|
|
1149
|
+
`,
|
|
1150
|
+
contact: {
|
|
1151
|
+
email: 'support@tellescope.com',
|
|
1152
|
+
url: 'https://tellescope.com'
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
servers: [
|
|
1156
|
+
{ url: 'https://api.tellescope.com', description: 'Production' },
|
|
1157
|
+
{ url: 'https://staging-api.tellescope.com', description: 'Staging' }
|
|
1158
|
+
],
|
|
1159
|
+
paths: {},
|
|
1160
|
+
components: {
|
|
1161
|
+
schemas: {},
|
|
1162
|
+
securitySchemes: generateSecuritySchemes(),
|
|
1163
|
+
responses: {
|
|
1164
|
+
BadRequest: {
|
|
1165
|
+
description: 'Bad Request - Invalid input or validation error',
|
|
1166
|
+
content: {
|
|
1167
|
+
'application/json': {
|
|
1168
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
},
|
|
1172
|
+
Unauthorized: {
|
|
1173
|
+
description: 'Unauthorized - Invalid or missing authentication',
|
|
1174
|
+
content: {
|
|
1175
|
+
'application/json': {
|
|
1176
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
NotFound: {
|
|
1181
|
+
description: 'Not Found - Resource does not exist',
|
|
1182
|
+
content: {
|
|
1183
|
+
'application/json': {
|
|
1184
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
},
|
|
1190
|
+
tags: [],
|
|
1191
|
+
security: [{ bearerAuth: [] }, { apiKey: [] }]
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Generate schemas
|
|
1195
|
+
spec.components.schemas = generateComponents()
|
|
1196
|
+
|
|
1197
|
+
// Generate paths for each model
|
|
1198
|
+
for (const [modelName, modelDef] of Object.entries(schema)) {
|
|
1199
|
+
const model = modelDef as Model<any, any>
|
|
1200
|
+
|
|
1201
|
+
// Add tag for this model
|
|
1202
|
+
spec.tags.push({
|
|
1203
|
+
name: pascalCase(modelName),
|
|
1204
|
+
description: model.info?.description || `${pascalCase(modelName)} resource operations`
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
// Generate default CRUD paths
|
|
1208
|
+
const crudPaths = generateDefaultOperationPaths(modelName, model)
|
|
1209
|
+
spec.paths = mergePaths(spec.paths, crudPaths)
|
|
1210
|
+
|
|
1211
|
+
// Generate custom action paths
|
|
1212
|
+
if (model.customActions && Object.keys(model.customActions).length > 0) {
|
|
1213
|
+
const customPaths = generateCustomActionPaths(modelName, model.customActions)
|
|
1214
|
+
spec.paths = mergePaths(spec.paths, customPaths)
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Sort tags alphabetically
|
|
1219
|
+
spec.tags.sort((a, b) => a.name.localeCompare(b.name))
|
|
1220
|
+
|
|
1221
|
+
// Sort paths alphabetically
|
|
1222
|
+
const sortedPaths: Record<string, OpenAPIPathItem> = {}
|
|
1223
|
+
for (const key of Object.keys(spec.paths).sort()) {
|
|
1224
|
+
sortedPaths[key] = spec.paths[key]
|
|
1225
|
+
}
|
|
1226
|
+
spec.paths = sortedPaths
|
|
1227
|
+
|
|
1228
|
+
return spec
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ============================================================================
|
|
1232
|
+
// CLI Execution
|
|
1233
|
+
// ============================================================================
|
|
1234
|
+
|
|
1235
|
+
async function main() {
|
|
1236
|
+
console.log('Generating OpenAPI specification from Tellescope schema...\n')
|
|
1237
|
+
|
|
1238
|
+
const spec = generateOpenAPISpec()
|
|
1239
|
+
|
|
1240
|
+
// Determine output path
|
|
1241
|
+
const outputPath = process.argv[2] || path.join(__dirname, '..', 'openapi.json')
|
|
1242
|
+
const absolutePath = path.isAbsolute(outputPath) ? outputPath : path.resolve(process.cwd(), outputPath)
|
|
1243
|
+
|
|
1244
|
+
// Write the spec
|
|
1245
|
+
fs.writeFileSync(absolutePath, JSON.stringify(spec, null, 2), 'utf-8')
|
|
1246
|
+
|
|
1247
|
+
// Print summary
|
|
1248
|
+
const pathCount = Object.keys(spec.paths).length
|
|
1249
|
+
const schemaCount = Object.keys(spec.components.schemas).length
|
|
1250
|
+
const tagCount = spec.tags.length
|
|
1251
|
+
|
|
1252
|
+
console.log('OpenAPI specification generated successfully!')
|
|
1253
|
+
console.log(` Output: ${absolutePath}`)
|
|
1254
|
+
console.log(` Paths: ${pathCount}`)
|
|
1255
|
+
console.log(` Schemas: ${schemaCount}`)
|
|
1256
|
+
console.log(` Tags (models): ${tagCount}`)
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Run if executed directly
|
|
1260
|
+
if (require.main === module) {
|
|
1261
|
+
main().catch(err => {
|
|
1262
|
+
console.error('Error generating OpenAPI spec:', err)
|
|
1263
|
+
process.exit(1)
|
|
1264
|
+
})
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Export for programmatic use
|
|
1268
|
+
export { generateOpenAPISpec, OpenAPISpec }
|