@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.
@@ -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 }