qdadm 0.28.0 → 0.30.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.
@@ -0,0 +1,568 @@
1
+ /**
2
+ * OpenAPI Connector
3
+ *
4
+ * Parses OpenAPI 3.x specifications into UnifiedEntitySchema format.
5
+ * Configurable path patterns, data wrapper, and operation filtering.
6
+ *
7
+ * @module gen/connectors/OpenAPIConnector
8
+ */
9
+
10
+ import { BaseConnector } from './BaseConnector.js'
11
+ import { getDefaultType } from '../FieldMapper.js'
12
+
13
+ /**
14
+ * Default path patterns for entity extraction
15
+ * Matches common REST patterns: /api/entities and /api/entities/{id}
16
+ *
17
+ * @type {RegExp[]}
18
+ */
19
+ const DEFAULT_PATH_PATTERNS = [
20
+ /^\/api\/([a-z-]+)\/?$/, // /api/users/ or /api/users
21
+ /^\/api\/([a-z-]+)\/\{[^}]+\}$/ // /api/users/{id}
22
+ ]
23
+
24
+ /**
25
+ * HTTP methods that indicate CRUD operations
26
+ *
27
+ * @type {Readonly<Record<string, string>>}
28
+ */
29
+ const CRUD_METHODS = Object.freeze({
30
+ get: 'read',
31
+ post: 'create',
32
+ put: 'update',
33
+ patch: 'update',
34
+ delete: 'delete'
35
+ })
36
+
37
+ /**
38
+ * OpenAPI Connector options
39
+ *
40
+ * @typedef {import('./BaseConnector.js').ConnectorOptions & OpenAPIConnectorOptions} OpenAPIConnectorFullOptions
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} OpenAPIConnectorOptions
45
+ * @property {RegExp[]} [pathPatterns] - Path patterns to match entities (default: DEFAULT_PATH_PATTERNS)
46
+ * @property {string} [dataWrapper] - Property name wrapping response data (default: 'data')
47
+ * @property {OperationFilter} [operationFilter] - Filter function for operations
48
+ * @property {import('../FieldMapper.js').CustomMappings} [customMappings] - Custom type mappings for FieldMapper
49
+ */
50
+
51
+ /**
52
+ * Filter function for OpenAPI operations
53
+ *
54
+ * @callback OperationFilter
55
+ * @param {string} path - API path
56
+ * @param {string} method - HTTP method (lowercase)
57
+ * @param {object} operation - OpenAPI operation object
58
+ * @returns {boolean} - True to include, false to skip
59
+ */
60
+
61
+ /**
62
+ * OpenAPI Connector for parsing OpenAPI 3.x specs into UnifiedEntitySchema.
63
+ *
64
+ * @extends BaseConnector
65
+ *
66
+ * @example
67
+ * // Basic usage with defaults
68
+ * const connector = new OpenAPIConnector()
69
+ * const schemas = connector.parse(openapiSpec)
70
+ *
71
+ * @example
72
+ * // Custom path patterns for versioned API
73
+ * const connector = new OpenAPIConnector({
74
+ * pathPatterns: [
75
+ * /^\/api\/v1\/([a-z-]+)\/?$/,
76
+ * /^\/api\/v1\/([a-z-]+)\/\{[^}]+\}$/
77
+ * ]
78
+ * })
79
+ *
80
+ * @example
81
+ * // Filter to only public operations
82
+ * const connector = new OpenAPIConnector({
83
+ * operationFilter: (path, method, op) => !op.tags?.includes('internal')
84
+ * })
85
+ */
86
+ export class OpenAPIConnector extends BaseConnector {
87
+ /**
88
+ * Create a new OpenAPI connector
89
+ *
90
+ * @param {OpenAPIConnectorFullOptions} [options={}] - Connector options
91
+ */
92
+ constructor(options = {}) {
93
+ super(options)
94
+
95
+ /** @type {RegExp[]} */
96
+ this.pathPatterns = options.pathPatterns || DEFAULT_PATH_PATTERNS
97
+
98
+ /** @type {string} */
99
+ this.dataWrapper = options.dataWrapper ?? 'data'
100
+
101
+ /** @type {OperationFilter|null} */
102
+ this.operationFilter = options.operationFilter || null
103
+
104
+ /** @type {import('../FieldMapper.js').CustomMappings} */
105
+ this.customMappings = options.customMappings || {}
106
+ }
107
+
108
+ /**
109
+ * Parse OpenAPI spec into UnifiedEntitySchema array
110
+ *
111
+ * @param {object} source - OpenAPI 3.x specification object
112
+ * @returns {import('../schema.js').UnifiedEntitySchema[]} - Parsed entity schemas
113
+ * @throws {Error} - If source is invalid or parsing fails in strict mode
114
+ *
115
+ * @example
116
+ * const schemas = connector.parse({
117
+ * openapi: '3.0.0',
118
+ * paths: { '/api/users': { get: { ... } } }
119
+ * })
120
+ */
121
+ parse(source) {
122
+ this._validateSource(source)
123
+ const entities = this._extractEntities(source)
124
+ return Array.from(entities.values())
125
+ }
126
+
127
+ /**
128
+ * Parse with warnings for detailed feedback
129
+ *
130
+ * @param {object} source - OpenAPI 3.x specification object
131
+ * @returns {import('./BaseConnector.js').ParseResult} - Schemas and warnings
132
+ */
133
+ parseWithWarnings(source) {
134
+ /** @type {import('./BaseConnector.js').ParseWarning[]} */
135
+ const warnings = []
136
+
137
+ this._validateSource(source)
138
+ const entities = this._extractEntities(source, warnings)
139
+
140
+ return {
141
+ schemas: Array.from(entities.values()),
142
+ warnings
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Validate OpenAPI source object
148
+ *
149
+ * @private
150
+ * @param {object} source - Source to validate
151
+ * @throws {Error} - If source is invalid
152
+ */
153
+ _validateSource(source) {
154
+ if (!source || typeof source !== 'object') {
155
+ throw new Error(`${this.name}: source must be an object`)
156
+ }
157
+ if (!source.paths || typeof source.paths !== 'object') {
158
+ throw new Error(`${this.name}: source must have paths object`)
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Extract all entities from OpenAPI spec
164
+ *
165
+ * @private
166
+ * @param {object} source - OpenAPI spec
167
+ * @param {import('./BaseConnector.js').ParseWarning[]} [warnings=[]] - Warning collector
168
+ * @returns {Map<string, import('../schema.js').UnifiedEntitySchema>} - Entity map
169
+ */
170
+ _extractEntities(source, warnings = []) {
171
+ /** @type {Map<string, EntityWorkingData>} */
172
+ const entityData = new Map()
173
+
174
+ for (const [path, pathItem] of Object.entries(source.paths)) {
175
+ const entityName = this._extractEntityName(path)
176
+ if (!entityName) continue
177
+
178
+ if (!entityData.has(entityName)) {
179
+ entityData.set(entityName, {
180
+ name: entityName,
181
+ endpoint: this._buildEndpoint(path),
182
+ rawSchema: null,
183
+ fields: new Map()
184
+ })
185
+ }
186
+
187
+ const data = entityData.get(entityName)
188
+ this._processPath(source, path, pathItem, data, warnings)
189
+ }
190
+
191
+ // Convert working data to UnifiedEntitySchema
192
+ return this._convertToSchemas(entityData)
193
+ }
194
+
195
+ /**
196
+ * Extract entity name from path using configured patterns
197
+ *
198
+ * @private
199
+ * @param {string} path - API path
200
+ * @returns {string|null} - Entity name or null
201
+ */
202
+ _extractEntityName(path) {
203
+ for (const pattern of this.pathPatterns) {
204
+ const match = path.match(pattern)
205
+ if (match) {
206
+ return match[1]
207
+ }
208
+ }
209
+ return null
210
+ }
211
+
212
+ /**
213
+ * Build endpoint from path (use collection path, not item path)
214
+ *
215
+ * @private
216
+ * @param {string} path - API path
217
+ * @returns {string} - Collection endpoint
218
+ */
219
+ _buildEndpoint(path) {
220
+ // Remove path parameter suffix to get collection endpoint
221
+ return path.replace(/\/\{[^}]+\}$/, '')
222
+ }
223
+
224
+ /**
225
+ * Process a path and extract schema info
226
+ *
227
+ * @private
228
+ * @param {object} spec - Full OpenAPI spec (for $ref resolution)
229
+ * @param {string} path - API path
230
+ * @param {object} pathItem - OpenAPI path item
231
+ * @param {EntityWorkingData} entity - Working entity data
232
+ * @param {import('./BaseConnector.js').ParseWarning[]} warnings - Warning collector
233
+ */
234
+ _processPath(spec, path, pathItem, entity, warnings) {
235
+ for (const [method, operation] of Object.entries(pathItem)) {
236
+ if (!CRUD_METHODS[method]) continue
237
+ if (typeof operation !== 'object') continue
238
+
239
+ // Apply operation filter
240
+ if (this.operationFilter && !this.operationFilter(path, method, operation)) {
241
+ continue
242
+ }
243
+
244
+ const isList = this._isListOperation(path, method, operation, spec)
245
+
246
+ // Extract response schema
247
+ const responseSchema = this._extractResponseSchema(operation, spec)
248
+ if (responseSchema) {
249
+ const itemSchema = isList
250
+ ? this._extractArrayItemSchema(responseSchema, spec)
251
+ : responseSchema
252
+
253
+ if (itemSchema) {
254
+ entity.rawSchema = this._mergeSchemas(entity.rawSchema, itemSchema)
255
+ this._extractFields(itemSchema, entity.fields, spec, warnings, path)
256
+ }
257
+ }
258
+
259
+ // Extract request body schema
260
+ if (operation.requestBody) {
261
+ const requestSchema = this._extractRequestSchema(operation, spec)
262
+ if (requestSchema) {
263
+ this._extractFields(requestSchema, entity.fields, spec, warnings, path)
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Check if operation returns a list
271
+ *
272
+ * @private
273
+ * @param {string} path - API path
274
+ * @param {string} method - HTTP method
275
+ * @param {object} operation - OpenAPI operation
276
+ * @param {object} spec - Full spec for ref resolution
277
+ * @returns {boolean} - True if list operation
278
+ */
279
+ _isListOperation(path, method, operation, spec) {
280
+ // GET on collection path (no path parameter) is typically list
281
+ if (method === 'get' && !path.includes('{')) {
282
+ return true
283
+ }
284
+
285
+ // Check if response schema is array
286
+ const schema = this._extractResponseSchema(operation, spec)
287
+ if (schema?.type === 'array') {
288
+ return true
289
+ }
290
+
291
+ // Check for pagination indicators in full response
292
+ const fullSchema = this._extractFullResponseSchema(operation, spec)
293
+ if (fullSchema?.properties?.pagination || fullSchema?.properties?.meta) {
294
+ return true
295
+ }
296
+
297
+ return false
298
+ }
299
+
300
+ /**
301
+ * Extract response schema (unwrap from data wrapper)
302
+ *
303
+ * @private
304
+ * @param {object} operation - OpenAPI operation
305
+ * @param {object} spec - Full spec for ref resolution
306
+ * @returns {object|null} - Extracted schema
307
+ */
308
+ _extractResponseSchema(operation, spec) {
309
+ const fullSchema = this._extractFullResponseSchema(operation, spec)
310
+ if (!fullSchema) return null
311
+
312
+ // Unwrap from configured data wrapper
313
+ if (this.dataWrapper && fullSchema.properties?.[this.dataWrapper]) {
314
+ const wrapped = fullSchema.properties[this.dataWrapper]
315
+ return wrapped.$ref ? this._resolveRef(wrapped.$ref, spec) : wrapped
316
+ }
317
+
318
+ return fullSchema
319
+ }
320
+
321
+ /**
322
+ * Extract full response schema without unwrapping
323
+ *
324
+ * @private
325
+ * @param {object} operation - OpenAPI operation
326
+ * @param {object} spec - Full spec for ref resolution
327
+ * @returns {object|null} - Full response schema
328
+ */
329
+ _extractFullResponseSchema(operation, spec) {
330
+ const response = operation.responses?.['200'] || operation.responses?.['201']
331
+ if (!response) return null
332
+
333
+ const content = response.content?.['application/json']
334
+ if (!content) return null
335
+
336
+ if (content.schema?.$ref) {
337
+ return this._resolveRef(content.schema.$ref, spec)
338
+ }
339
+
340
+ return content.schema || null
341
+ }
342
+
343
+ /**
344
+ * Extract request body schema
345
+ *
346
+ * @private
347
+ * @param {object} operation - OpenAPI operation
348
+ * @param {object} spec - Full spec for ref resolution
349
+ * @returns {object|null} - Request schema
350
+ */
351
+ _extractRequestSchema(operation, spec) {
352
+ const content = operation.requestBody?.content?.['application/json']
353
+ if (!content) return null
354
+
355
+ if (content.schema?.$ref) {
356
+ return this._resolveRef(content.schema.$ref, spec)
357
+ }
358
+
359
+ return content.schema || null
360
+ }
361
+
362
+ /**
363
+ * Resolve a $ref to its schema
364
+ *
365
+ * @private
366
+ * @param {string} ref - Reference string (e.g., '#/components/schemas/User')
367
+ * @param {object} spec - Full spec
368
+ * @returns {object|null} - Resolved schema
369
+ */
370
+ _resolveRef(ref, spec) {
371
+ if (!ref.startsWith('#/')) return null
372
+
373
+ const parts = ref.slice(2).split('/')
374
+ let current = spec
375
+
376
+ for (const part of parts) {
377
+ if (!current || typeof current !== 'object') return null
378
+ current = current[part]
379
+ }
380
+
381
+ return current || null
382
+ }
383
+
384
+ /**
385
+ * Extract item schema from array schema
386
+ *
387
+ * @private
388
+ * @param {object} schema - Array schema
389
+ * @param {object} spec - Full spec for ref resolution
390
+ * @returns {object|null} - Item schema
391
+ */
392
+ _extractArrayItemSchema(schema, spec) {
393
+ if (schema.type === 'array' && schema.items) {
394
+ if (schema.items.$ref) {
395
+ return this._resolveRef(schema.items.$ref, spec)
396
+ }
397
+ return schema.items
398
+ }
399
+ return schema
400
+ }
401
+
402
+ /**
403
+ * Extract fields from JSON Schema and add to field map
404
+ *
405
+ * @private
406
+ * @param {object} schema - JSON Schema object
407
+ * @param {Map<string, import('../schema.js').UnifiedFieldSchema>} fields - Field map to populate
408
+ * @param {object} spec - Full spec for ref resolution
409
+ * @param {import('./BaseConnector.js').ParseWarning[]} warnings - Warning collector
410
+ * @param {string} sourcePath - Source path for warnings
411
+ * @param {string} [prefix=''] - Prefix for nested field names
412
+ */
413
+ _extractFields(schema, fields, spec, warnings, sourcePath, prefix = '') {
414
+ if (!schema || !schema.properties) {
415
+ return
416
+ }
417
+
418
+ const required = new Set(schema.required || [])
419
+
420
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
421
+ const fieldName = prefix ? `${prefix}.${name}` : name
422
+
423
+ // Resolve $ref in property schema
424
+ const resolvedSchema = propSchema.$ref
425
+ ? this._resolveRef(propSchema.$ref, spec)
426
+ : propSchema
427
+
428
+ if (!resolvedSchema) {
429
+ warnings.push({
430
+ path: `${sourcePath}#${fieldName}`,
431
+ message: `Could not resolve schema for field: ${fieldName}`,
432
+ code: 'UNRESOLVED_REF'
433
+ })
434
+ continue
435
+ }
436
+
437
+ // Use FieldMapper for type conversion
438
+ const type = getDefaultType(resolvedSchema, this.customMappings)
439
+
440
+ /** @type {import('../schema.js').UnifiedFieldSchema} */
441
+ const field = {
442
+ name: fieldName,
443
+ type,
444
+ required: required.has(name),
445
+ readOnly: resolvedSchema.readOnly || false
446
+ }
447
+
448
+ // Add optional properties
449
+ if (resolvedSchema.description) {
450
+ field.label = resolvedSchema.description
451
+ }
452
+ if (resolvedSchema.format) {
453
+ field.format = resolvedSchema.format
454
+ }
455
+ if (resolvedSchema.enum) {
456
+ field.enum = resolvedSchema.enum
457
+ }
458
+ if (resolvedSchema.default !== undefined) {
459
+ field.default = resolvedSchema.default
460
+ }
461
+
462
+ // Merge with existing field (may have more info from other operations)
463
+ if (fields.has(fieldName)) {
464
+ const existing = fields.get(fieldName)
465
+ this._mergeField(existing, field)
466
+ } else {
467
+ fields.set(fieldName, field)
468
+ }
469
+
470
+ // Handle nested objects (one level only to avoid complexity)
471
+ if (resolvedSchema.type === 'object' && resolvedSchema.properties && !prefix) {
472
+ this._extractFields(resolvedSchema, fields, spec, warnings, sourcePath, fieldName)
473
+ }
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Merge field info from secondary source into primary
479
+ *
480
+ * @private
481
+ * @param {import('../schema.js').UnifiedFieldSchema} primary - Primary field to merge into
482
+ * @param {import('../schema.js').UnifiedFieldSchema} secondary - Secondary field with additional info
483
+ */
484
+ _mergeField(primary, secondary) {
485
+ if (!primary.label && secondary.label) {
486
+ primary.label = secondary.label
487
+ }
488
+ if (!primary.enum && secondary.enum) {
489
+ primary.enum = secondary.enum
490
+ }
491
+ if (!primary.required && secondary.required) {
492
+ primary.required = true
493
+ }
494
+ if (primary.default === undefined && secondary.default !== undefined) {
495
+ primary.default = secondary.default
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Merge two JSON Schemas
501
+ *
502
+ * @private
503
+ * @param {object|null} base - Base schema
504
+ * @param {object} other - Schema to merge
505
+ * @returns {object} - Merged schema
506
+ */
507
+ _mergeSchemas(base, other) {
508
+ if (!base) return other
509
+ if (!other) return base
510
+
511
+ const merged = { ...base }
512
+
513
+ if (other.properties) {
514
+ merged.properties = {
515
+ ...(base.properties || {}),
516
+ ...other.properties
517
+ }
518
+ }
519
+
520
+ if (other.required) {
521
+ merged.required = [...new Set([...(base.required || []), ...other.required])]
522
+ }
523
+
524
+ return merged
525
+ }
526
+
527
+ /**
528
+ * Convert working entity data to UnifiedEntitySchema
529
+ *
530
+ * @private
531
+ * @param {Map<string, EntityWorkingData>} entityData - Working data
532
+ * @returns {Map<string, import('../schema.js').UnifiedEntitySchema>} - Final schemas
533
+ */
534
+ _convertToSchemas(entityData) {
535
+ /** @type {Map<string, import('../schema.js').UnifiedEntitySchema>} */
536
+ const schemas = new Map()
537
+
538
+ for (const [name, data] of entityData) {
539
+ /** @type {import('../schema.js').UnifiedEntitySchema} */
540
+ const schema = {
541
+ name: data.name,
542
+ endpoint: data.endpoint,
543
+ fields: Object.fromEntries(data.fields)
544
+ }
545
+
546
+ // Add extensions if configured
547
+ if (Object.keys(this.extensions).length > 0) {
548
+ schema.extensions = { ...this.extensions }
549
+ }
550
+
551
+ schemas.set(name, schema)
552
+ }
553
+
554
+ return schemas
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Working data structure for entity extraction
560
+ *
561
+ * @typedef {object} EntityWorkingData
562
+ * @property {string} name - Entity name
563
+ * @property {string} endpoint - Collection endpoint
564
+ * @property {object|null} rawSchema - Merged raw JSON Schema
565
+ * @property {Map<string, import('../schema.js').UnifiedFieldSchema>} fields - Extracted fields
566
+ */
567
+
568
+ export default OpenAPIConnector