@qualisero/openapi-endpoint 0.12.3 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,7 +2,12 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { exec } from 'child_process';
4
4
  import { promisify } from 'util';
5
+ import { HttpMethod } from './types.js';
5
6
  const execAsync = promisify(exec);
7
+ /**
8
+ * Standard HTTP methods used in OpenAPI specifications.
9
+ */
10
+ const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'];
6
11
  async function fetchOpenAPISpec(input) {
7
12
  // Check if input is a URL
8
13
  if (input.startsWith('http://') || input.startsWith('https://')) {
@@ -174,8 +179,7 @@ function addMissingOperationIds(openApiSpec, prefixToStrip = '/api') {
174
179
  // First pass: collect existing operationIds
175
180
  Object.entries(openApiSpec.paths).forEach(([_, pathItem]) => {
176
181
  Object.entries(pathItem).forEach(([method, op]) => {
177
- const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'];
178
- if (!httpMethods.includes(method.toLowerCase())) {
182
+ if (!HTTP_METHODS.includes(method.toLowerCase())) {
179
183
  return;
180
184
  }
181
185
  if (op.operationId) {
@@ -186,8 +190,7 @@ function addMissingOperationIds(openApiSpec, prefixToStrip = '/api') {
186
190
  // Second pass: generate operationIds for missing ones
187
191
  Object.entries(openApiSpec.paths).forEach(([pathUrl, pathItem]) => {
188
192
  Object.entries(pathItem).forEach(([method, op]) => {
189
- const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'];
190
- if (!httpMethods.includes(method.toLowerCase())) {
193
+ if (!HTTP_METHODS.includes(method.toLowerCase())) {
191
194
  return;
192
195
  }
193
196
  if (!op.operationId) {
@@ -200,7 +203,7 @@ function addMissingOperationIds(openApiSpec, prefixToStrip = '/api') {
200
203
  });
201
204
  });
202
205
  }
203
- function parseOperationsFromSpec(openapiContent) {
206
+ function parseOperationsFromSpec(openapiContent, excludePrefix = '_deprecated') {
204
207
  const openApiSpec = JSON.parse(openapiContent);
205
208
  if (!openApiSpec.paths) {
206
209
  throw new Error('Invalid OpenAPI spec: missing paths');
@@ -211,12 +214,16 @@ function parseOperationsFromSpec(openapiContent) {
211
214
  Object.entries(openApiSpec.paths).forEach(([pathUrl, pathItem]) => {
212
215
  Object.entries(pathItem).forEach(([method, operation]) => {
213
216
  // Skip non-HTTP methods (like parameters)
214
- const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'];
215
- if (!httpMethods.includes(method.toLowerCase())) {
217
+ if (!HTTP_METHODS.includes(method.toLowerCase())) {
216
218
  return;
217
219
  }
218
220
  const op = operation;
219
221
  if (op.operationId) {
222
+ // Skip operations with excluded prefix
223
+ if (excludePrefix && op.operationId.startsWith(excludePrefix)) {
224
+ console.log(`⏭️ Excluding operation: ${op.operationId} (matches prefix '${excludePrefix}')`);
225
+ return;
226
+ }
220
227
  operationIds.push(op.operationId);
221
228
  operationInfoMap[op.operationId] = {
222
229
  path: pathUrl,
@@ -228,6 +235,502 @@ function parseOperationsFromSpec(openapiContent) {
228
235
  operationIds.sort();
229
236
  return { operationIds, operationInfoMap };
230
237
  }
238
+ /**
239
+ * Converts a string to camelCase or PascalCase.
240
+ * Handles snake_case, kebab-case, space-separated strings, and mixed cases.
241
+ * Single source of truth for case conversion logic.
242
+ *
243
+ * @param str - Input string to convert
244
+ * @param capitalize - If true, returns PascalCase; if false, returns camelCase
245
+ * @returns Converted string in the requested case
246
+ */
247
+ function toCase(str, capitalize) {
248
+ // If already camelCase or PascalCase, just adjust first letter
249
+ if (/[a-z]/.test(str) && /[A-Z]/.test(str)) {
250
+ return capitalize ? str.charAt(0).toUpperCase() + str.slice(1) : str.charAt(0).toLowerCase() + str.slice(1);
251
+ }
252
+ // Handle snake_case, kebab-case, spaces, etc.
253
+ const parts = str
254
+ .split(/[-_\s]+/)
255
+ .filter((part) => part.length > 0)
256
+ .map((part) => {
257
+ // If this part is already in camelCase, just capitalize the first letter
258
+ if (/[a-z]/.test(part) && /[A-Z]/.test(part)) {
259
+ return part.charAt(0).toUpperCase() + part.slice(1);
260
+ }
261
+ // Otherwise, capitalize and lowercase to normalize
262
+ return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
263
+ });
264
+ if (parts.length === 0)
265
+ return str;
266
+ // Apply capitalization rule to first part
267
+ if (!capitalize) {
268
+ parts[0] = parts[0].charAt(0).toLowerCase() + parts[0].slice(1);
269
+ }
270
+ return parts.join('');
271
+ }
272
+ /**
273
+ * Converts a string to PascalCase.
274
+ * Handles snake_case, kebab-case, space-separated strings, and preserves existing camelCase.
275
+ */
276
+ function toPascalCase(str) {
277
+ return toCase(str, true);
278
+ }
279
+ /**
280
+ * Converts a string value to a valid TypeScript property name.
281
+ * - Strings that are valid identifiers are used as-is (capitalized)
282
+ * - Invalid identifiers are wrapped in quotes
283
+ * - Numbers are prefixed with underscore
284
+ */
285
+ function toEnumMemberName(value) {
286
+ if (value === null) {
287
+ return 'Null'; // Handle null enum values
288
+ }
289
+ if (typeof value === 'number') {
290
+ return `_${value}`; // Numbers can't be property names, prefix with underscore
291
+ }
292
+ // Map common operator symbols to readable names
293
+ const operatorMap = {
294
+ '=': 'Equals',
295
+ '!=': 'NotEquals',
296
+ '<': 'LessThan',
297
+ '>': 'GreaterThan',
298
+ '<=': 'LessThanOrEqual',
299
+ '>=': 'GreaterThanOrEqual',
300
+ '!': 'Not',
301
+ '&&': 'And',
302
+ '||': 'Or',
303
+ '+': 'Plus',
304
+ '-': 'Minus',
305
+ '*': 'Multiply',
306
+ '/': 'Divide',
307
+ '%': 'Modulo',
308
+ '^': 'Caret',
309
+ '&': 'Ampersand',
310
+ '|': 'Pipe',
311
+ '~': 'Tilde',
312
+ '<<': 'LeftShift',
313
+ '>>': 'RightShift',
314
+ };
315
+ if (operatorMap[value]) {
316
+ return operatorMap[value];
317
+ }
318
+ // Check if it's a valid TypeScript identifier
319
+ const isValidIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value);
320
+ if (isValidIdentifier) {
321
+ // Capitalize first letter for convention
322
+ return value.charAt(0).toUpperCase() + value.slice(1);
323
+ }
324
+ // For non-identifier strings, replace special characters with underscores
325
+ const cleaned = toPascalCase(value.replace(/[^a-zA-Z0-9_$]/g, '_'));
326
+ // If the result is empty or still invalid, prefix with underscore to make it valid
327
+ if (cleaned.length === 0 || !/^[a-zA-Z_$]/.test(cleaned)) {
328
+ return `_Char${value.charCodeAt(0)}`;
329
+ }
330
+ return cleaned;
331
+ }
332
+ /**
333
+ * Helper function to add enum values to the enums list with deduplication.
334
+ * If a duplicate is found, it adds the new name as an alias instead of creating a separate enum.
335
+ */
336
+ function addEnumIfUnique(enumName, enumValues, sourcePath, enums, seenEnumValues) {
337
+ const valuesKey = JSON.stringify(enumValues.sort());
338
+ // Check if we've seen this exact set of values before
339
+ const existingName = seenEnumValues.get(valuesKey);
340
+ if (existingName) {
341
+ // Find the existing enum and add this as an alias
342
+ const existingEnum = enums.find((e) => e.name === existingName);
343
+ if (existingEnum) {
344
+ if (!existingEnum.aliases) {
345
+ existingEnum.aliases = [];
346
+ }
347
+ existingEnum.aliases.push(enumName);
348
+ console.log(` ↳ Adding alias ${enumName} → ${existingName}`);
349
+ }
350
+ return;
351
+ }
352
+ seenEnumValues.set(valuesKey, enumName);
353
+ enums.push({
354
+ name: enumName,
355
+ values: enumValues,
356
+ sourcePath,
357
+ });
358
+ }
359
+ /**
360
+ * Extracts all enums from an OpenAPI spec.
361
+ * Walks through:
362
+ * 1. components.schemas and their properties
363
+ * 2. Operation parameters (query, header, path, cookie)
364
+ * Deduplicates by comparing enum value sets.
365
+ */
366
+ function extractEnumsFromSpec(openApiSpec) {
367
+ const enums = [];
368
+ const seenEnumValues = new Map(); // Maps JSON stringified values -> enum name (for deduplication)
369
+ // Extract from components.schemas
370
+ if (openApiSpec.components?.schemas) {
371
+ for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) {
372
+ if (!schema.properties)
373
+ continue;
374
+ for (const [propName, propSchema] of Object.entries(schema.properties)) {
375
+ if (!propSchema.enum || !Array.isArray(propSchema.enum))
376
+ continue;
377
+ // Filter out null values from enum array
378
+ const enumValues = propSchema.enum.filter((v) => v !== null);
379
+ // Skip if all values were null
380
+ if (enumValues.length === 0)
381
+ continue;
382
+ // Use schema name as-is (already PascalCase), convert property name from snake_case
383
+ const enumName = schemaName + toPascalCase(propName);
384
+ addEnumIfUnique(enumName, enumValues, `components.schemas.${schemaName}.properties.${propName}`, enums, seenEnumValues);
385
+ }
386
+ }
387
+ }
388
+ // Extract from operation parameters
389
+ if (openApiSpec.paths) {
390
+ for (const [pathUrl, pathItem] of Object.entries(openApiSpec.paths)) {
391
+ for (const [method, operation] of Object.entries(pathItem)) {
392
+ // Skip non-HTTP methods
393
+ if (!HTTP_METHODS.includes(method.toLowerCase())) {
394
+ continue;
395
+ }
396
+ const op = operation;
397
+ // Check parameters (query, header, path, cookie)
398
+ if (op.parameters && Array.isArray(op.parameters)) {
399
+ for (const param of op.parameters) {
400
+ const paramObj = param;
401
+ const paramName = paramObj.name;
402
+ const paramIn = paramObj.in;
403
+ const paramSchema = paramObj.schema;
404
+ if (!paramName || !paramIn || !paramSchema?.enum)
405
+ continue;
406
+ const enumValues = paramSchema.enum.filter((v) => v !== null);
407
+ if (enumValues.length === 0)
408
+ continue;
409
+ // Create a descriptive name: OperationName + ParamName
410
+ const operationName = op.operationId
411
+ ? toPascalCase(op.operationId)
412
+ : toPascalCase(pathUrl.split('/').pop() || 'param');
413
+ const paramNamePascal = toPascalCase(paramName);
414
+ // Rule 1: Don't duplicate suffix if operation name already ends with param name
415
+ let enumName;
416
+ if (operationName.endsWith(paramNamePascal)) {
417
+ enumName = operationName;
418
+ }
419
+ else {
420
+ enumName = operationName + paramNamePascal;
421
+ }
422
+ const sourcePath = `paths.${pathUrl}.${method}.parameters[${paramName}]`;
423
+ addEnumIfUnique(enumName, enumValues, sourcePath, enums, seenEnumValues);
424
+ }
425
+ }
426
+ }
427
+ }
428
+ }
429
+ // Sort by name for consistent output
430
+ enums.sort((a, b) => a.name.localeCompare(b.name));
431
+ // Rule 2: Create short aliases for common suffixes (>2 words, appears >2 times)
432
+ addCommonSuffixAliases(enums);
433
+ return enums;
434
+ }
435
+ /**
436
+ * Rule 2: Analyzes enum names and creates short aliases for common suffixes.
437
+ * Algorithm:
438
+ * 1. Find all suffixes > 2 words that appear 3+ times
439
+ * 2. Sort by number of occurrences (descending)
440
+ * 3. Remove any suffix that is a suffix of a MORE common one
441
+ * 4. Create aliases for remaining suffixes
442
+ */
443
+ function addCommonSuffixAliases(enums) {
444
+ // Split enum names into words (by capital letters)
445
+ const splitIntoWords = (name) => {
446
+ return name.split(/(?=[A-Z])/).filter((w) => w.length > 0);
447
+ };
448
+ // Collect ALL enum names (primary + aliases)
449
+ const allEnumNames = [];
450
+ for (const enumInfo of enums) {
451
+ allEnumNames.push(enumInfo.name);
452
+ if (enumInfo.aliases) {
453
+ allEnumNames.push(...enumInfo.aliases);
454
+ }
455
+ }
456
+ // Extract all possible multi-word suffixes from ALL names
457
+ const suffixCounts = new Map(); // suffix -> set of full enum names
458
+ for (const name of allEnumNames) {
459
+ const words = splitIntoWords(name);
460
+ // Try all suffixes with 3+ words
461
+ for (let wordCount = 3; wordCount <= words.length - 1; wordCount++) {
462
+ // -1 to exclude the full name
463
+ const suffix = words.slice(-wordCount).join('');
464
+ if (!suffixCounts.has(suffix)) {
465
+ suffixCounts.set(suffix, new Set());
466
+ }
467
+ suffixCounts.get(suffix).add(name);
468
+ }
469
+ }
470
+ // Step 1: Find suffixes appearing 3+ times
471
+ const commonSuffixes = [];
472
+ for (const [suffix, enumNames] of suffixCounts.entries()) {
473
+ if (enumNames.size > 2) {
474
+ // Skip if this suffix is already present as a primary enum name or alias
475
+ if (allEnumNames.includes(suffix)) {
476
+ continue;
477
+ }
478
+ commonSuffixes.push({
479
+ suffix,
480
+ count: enumNames.size,
481
+ names: Array.from(enumNames),
482
+ });
483
+ }
484
+ }
485
+ // Step 2: Sort by occurrence count (descending - most common first)
486
+ commonSuffixes.sort((a, b) => b.count - a.count);
487
+ // Step 3: Remove suffixes that are suffixes of MORE common ones
488
+ const filteredSuffixes = [];
489
+ for (const current of commonSuffixes) {
490
+ let shouldKeep = true;
491
+ // Check if this suffix is a suffix of any MORE common suffix already in the filtered list
492
+ for (const existing of filteredSuffixes) {
493
+ if (existing.suffix.endsWith(current.suffix)) {
494
+ // current is a suffix of existing (which is more common)
495
+ shouldKeep = false;
496
+ break;
497
+ }
498
+ }
499
+ if (shouldKeep) {
500
+ filteredSuffixes.push(current);
501
+ }
502
+ }
503
+ // Step 4: PROMOTE common suffixes to be PRIMARY enum names
504
+ // Process promotions from most common to least common
505
+ const promotions = new Map();
506
+ for (const { suffix, names } of filteredSuffixes) {
507
+ // Find all primary enums that have this suffix (either as primary name or alias)
508
+ const affectedEnums = [];
509
+ for (const name of names) {
510
+ const enumInfo = enums.find((e) => e.name === name || (e.aliases && e.aliases.includes(name)));
511
+ if (enumInfo && !affectedEnums.includes(enumInfo) && !promotions.has(enumInfo)) {
512
+ affectedEnums.push(enumInfo);
513
+ }
514
+ }
515
+ if (affectedEnums.length === 0)
516
+ continue;
517
+ // Use the first affected enum as the base (it has the values we need)
518
+ const primaryEnum = affectedEnums[0];
519
+ // Collect all names that should become aliases
520
+ const allAliases = new Set();
521
+ for (const enumInfo of affectedEnums) {
522
+ // Add the primary name as an alias
523
+ allAliases.add(enumInfo.name);
524
+ // Add all existing aliases
525
+ if (enumInfo.aliases) {
526
+ enumInfo.aliases.forEach((alias) => allAliases.add(alias));
527
+ }
528
+ }
529
+ // Remove the suffix itself from aliases (it will be the primary name)
530
+ allAliases.delete(suffix);
531
+ // Record this promotion to apply later
532
+ promotions.set(primaryEnum, {
533
+ newName: suffix,
534
+ allAliases: Array.from(allAliases),
535
+ });
536
+ console.log(` ↳ Promoting ${suffix} to PRIMARY (was ${primaryEnum.name}, ${names.length} occurrences)`);
537
+ // Mark other affected enums for removal
538
+ for (let i = 1; i < affectedEnums.length; i++) {
539
+ promotions.set(affectedEnums[i], { newName: '', allAliases: [] }); // Mark for deletion
540
+ }
541
+ }
542
+ // Apply all promotions
543
+ for (const [enumInfo, promotion] of promotions.entries()) {
544
+ if (promotion.newName === '') {
545
+ // Remove this enum (it was consolidated)
546
+ const index = enums.indexOf(enumInfo);
547
+ if (index > -1) {
548
+ enums.splice(index, 1);
549
+ }
550
+ }
551
+ else {
552
+ // Update the enum name and aliases
553
+ enumInfo.name = promotion.newName;
554
+ enumInfo.aliases = promotion.allAliases;
555
+ enumInfo.sourcePath = `common suffix (promoted)`;
556
+ }
557
+ }
558
+ }
559
+ /**
560
+ * Generates the content for api-enums.ts file.
561
+ */
562
+ function generateApiEnumsContent(enums) {
563
+ if (enums.length === 0) {
564
+ return `// Auto-generated from OpenAPI specification
565
+ // Do not edit this file manually
566
+
567
+ // No enums found in the OpenAPI specification
568
+ `;
569
+ }
570
+ // Generate the generic enum helper utility
571
+ const helperUtility = `/**
572
+ * Generic utility for working with enums
573
+ *
574
+ * @example
575
+ * import { EnumHelper, RequestedValuationType } from './api-enums'
576
+ *
577
+ * // Get all values
578
+ * const allTypes = EnumHelper.values(RequestedValuationType)
579
+ *
580
+ * // Validate a value
581
+ * if (EnumHelper.isValid(RequestedValuationType, userInput)) {
582
+ * // TypeScript knows userInput is RequestedValuationType
583
+ * }
584
+ *
585
+ * // Reverse lookup
586
+ * const key = EnumHelper.getKey(RequestedValuationType, 'cat') // 'Cat'
587
+ */
588
+ export const EnumHelper = {
589
+ /**
590
+ * Get all enum values as an array
591
+ */
592
+ values<T extends Record<string, string | number>>(enumObj: T): Array<T[keyof T]> {
593
+ return Object.values(enumObj) as Array<T[keyof T]>
594
+ },
595
+
596
+ /**
597
+ * Get all enum keys as an array
598
+ */
599
+ keys<T extends Record<string, string | number>>(enumObj: T): Array<keyof T> {
600
+ return Object.keys(enumObj) as Array<keyof T>
601
+ },
602
+
603
+ /**
604
+ * Check if a value is valid for the given enum
605
+ */
606
+ isValid<T extends Record<string, string | number>>(
607
+ enumObj: T,
608
+ value: unknown,
609
+ ): value is T[keyof T] {
610
+ return typeof value === 'string' && (Object.values(enumObj) as string[]).includes(value)
611
+ },
612
+
613
+ /**
614
+ * Get the enum key from a value (reverse lookup)
615
+ */
616
+ getKey<T extends Record<string, string | number>>(enumObj: T, value: T[keyof T]): keyof T | undefined {
617
+ const entry = Object.entries(enumObj).find(([_, v]) => v === value)
618
+ return entry?.[0] as keyof T | undefined
619
+ },
620
+ } as const
621
+ `;
622
+ const enumExports = enums
623
+ .map((enumInfo) => {
624
+ const members = enumInfo.values
625
+ .map((value) => {
626
+ const memberName = toEnumMemberName(value);
627
+ const valueStr = typeof value === 'string' ? `'${value}'` : value;
628
+ return ` ${memberName}: ${valueStr} as const,`;
629
+ })
630
+ .join('\n');
631
+ let output = `/**
632
+ * Enum values from ${enumInfo.sourcePath}
633
+ */
634
+ export const ${enumInfo.name} = {
635
+ ${members}
636
+ } as const
637
+
638
+ export type ${enumInfo.name} = typeof ${enumInfo.name}[keyof typeof ${enumInfo.name}]
639
+ `;
640
+ // Generate type aliases for duplicates
641
+ if (enumInfo.aliases && enumInfo.aliases.length > 0) {
642
+ output += '\n// Type aliases for duplicate enum values\n';
643
+ for (const alias of enumInfo.aliases) {
644
+ output += `export const ${alias} = ${enumInfo.name}\n`;
645
+ output += `export type ${alias} = ${enumInfo.name}\n`;
646
+ }
647
+ }
648
+ return output;
649
+ })
650
+ .join('\n');
651
+ return `// Auto-generated from OpenAPI specification
652
+ // Do not edit this file manually
653
+
654
+ ${helperUtility}
655
+
656
+ ${enumExports}
657
+ `;
658
+ }
659
+ /**
660
+ * Generates the api-enums.ts file from the OpenAPI spec.
661
+ */
662
+ async function generateApiEnums(openapiContent, outputDir, _excludePrefix = '_deprecated') {
663
+ console.log('🔨 Generating api-enums.ts file...');
664
+ const openApiSpec = JSON.parse(openapiContent);
665
+ const enums = extractEnumsFromSpec(openApiSpec);
666
+ const tsContent = generateApiEnumsContent(enums);
667
+ const outputPath = path.join(outputDir, 'api-enums.ts');
668
+ fs.writeFileSync(outputPath, tsContent);
669
+ console.log(`✅ Generated api-enums file: ${outputPath}`);
670
+ console.log(`📊 Found ${enums.length} unique enums`);
671
+ }
672
+ /**
673
+ * Removes trailing `_schema` or `Schema` suffix from a string (case-insensitive).
674
+ * Examples: `nuts_schema` → `nuts`, `addressSchema` → `address`, `Pet` → `Pet`
675
+ */
676
+ function removeSchemaSuffix(name) {
677
+ return name.replace(/(_schema|Schema)$/i, '');
678
+ }
679
+ /**
680
+ * Generates the content for api-schemas.ts file.
681
+ * Creates type aliases for all schema objects with cleaned names.
682
+ */
683
+ function generateApiSchemasContent(openApiSpec) {
684
+ if (!openApiSpec.components?.schemas || Object.keys(openApiSpec.components.schemas).length === 0) {
685
+ return `// Auto-generated from OpenAPI specification
686
+ // Do not edit this file manually
687
+
688
+ import type { components } from './openapi-types.js'
689
+
690
+ // No schemas found in the OpenAPI specification
691
+ `;
692
+ }
693
+ const header = `// Auto-generated from OpenAPI specification
694
+ // Do not edit this file manually
695
+
696
+ import type { components } from './openapi-types.js'
697
+
698
+ /**
699
+ * Type aliases for schema objects from the API spec.
700
+ * These are references to components['schemas'] for convenient importing.
701
+ *
702
+ * @example
703
+ * import type { Nuts, Address, BorrowerInfo } from './api-schemas'
704
+ *
705
+ * const nutsData: Nuts = { NUTS_ID: 'BE241', ... }
706
+ */
707
+ `;
708
+ const schemaExports = Object.keys(openApiSpec.components.schemas)
709
+ .sort()
710
+ .map((schemaName) => {
711
+ // Remove schema suffix and convert to PascalCase
712
+ const cleanedName = removeSchemaSuffix(schemaName);
713
+ const exportedName = toPascalCase(cleanedName);
714
+ // Only add comment if the name changed
715
+ const comment = exportedName !== schemaName ? `// Schema: ${schemaName}\n` : '';
716
+ return `${comment}export type ${exportedName} = components['schemas']['${schemaName}']`;
717
+ })
718
+ .join('\n\n');
719
+ return header + '\n' + schemaExports + '\n';
720
+ }
721
+ /**
722
+ * Generates the api-schemas.ts file from the OpenAPI spec.
723
+ */
724
+ async function generateApiSchemas(openapiContent, outputDir, _excludePrefix = '_deprecated') {
725
+ console.log('🔨 Generating api-schemas.ts file...');
726
+ const openApiSpec = JSON.parse(openapiContent);
727
+ const schemaCount = Object.keys(openApiSpec.components?.schemas ?? {}).length;
728
+ const tsContent = generateApiSchemasContent(openApiSpec);
729
+ const outputPath = path.join(outputDir, 'api-schemas.ts');
730
+ fs.writeFileSync(outputPath, tsContent);
731
+ console.log(`✅ Generated api-schemas file: ${outputPath}`);
732
+ console.log(`📊 Found ${schemaCount} schemas`);
733
+ }
231
734
  function generateApiOperationsContent(operationIds, operationInfoMap) {
232
735
  // Generate operationsBase dictionary
233
736
  const operationsBaseContent = operationIds
@@ -236,12 +739,40 @@ function generateApiOperationsContent(operationIds, operationInfoMap) {
236
739
  return ` ${id}: {\n path: '${info.path}',\n method: HttpMethod.${info.method},\n },`;
237
740
  })
238
741
  .join('\n');
239
- // Generate OperationId enum content
240
- const operationIdContent = operationIds.map((id) => ` ${id}: '${id}' as const,`).join('\n');
742
+ const queryOperationIds = operationIds.filter((id) => {
743
+ const method = operationInfoMap[id]?.method;
744
+ return method === HttpMethod.GET || method === HttpMethod.HEAD || method === HttpMethod.OPTIONS;
745
+ });
746
+ const mutationOperationIds = operationIds.filter((id) => {
747
+ const method = operationInfoMap[id]?.method;
748
+ return (method === HttpMethod.POST ||
749
+ method === HttpMethod.PUT ||
750
+ method === HttpMethod.PATCH ||
751
+ method === HttpMethod.DELETE);
752
+ });
753
+ // Generate filtered OperationId enums (source of truth)
754
+ const queryOperationIdContent = queryOperationIds.map((id) => ` ${id}: '${id}' as const,`).join('\n');
755
+ const mutationOperationIdContent = mutationOperationIds.map((id) => ` ${id}: '${id}' as const,`).join('\n');
756
+ // Generate OpType namespace from BOTH lists
757
+ const opTypeContent = [
758
+ ...queryOperationIds.map((id) => ` export type ${id} = typeof QueryOperationId.${id}`),
759
+ ...mutationOperationIds.map((id) => ` export type ${id} = typeof MutationOperationId.${id}`),
760
+ ].join('\n');
761
+ // Generate pre-computed type alias content for Phase 3B
762
+ const queryParamsContent = queryOperationIds.map((id) => ` ${id}: ApiQueryParams<OpType.${id}>`).join('\n');
763
+ const mutationParamsContent = mutationOperationIds.map((id) => ` ${id}: ApiPathParams<OpType.${id}>`).join('\n');
764
+ const mutationBodyContent = mutationOperationIds.map((id) => ` ${id}: ApiRequest<OpType.${id}>`).join('\n');
241
765
  return `// Auto-generated from OpenAPI specification
242
766
  // Do not edit this file manually
243
767
 
244
768
  import type { operations } from './openapi-types.js'
769
+ import type {
770
+ ApiResponse as ApiResponseBase,
771
+ ApiResponseSafe as ApiResponseSafeBase,
772
+ ApiRequest as ApiRequestBase,
773
+ ApiPathParams as ApiPathParamsBase,
774
+ ApiQueryParams as ApiQueryParamsBase,
775
+ } from '@qualisero/openapi-endpoint'
245
776
 
246
777
  export enum HttpMethod {
247
778
  GET = 'GET',
@@ -261,22 +792,166 @@ ${operationsBaseContent}
261
792
  } as const
262
793
 
263
794
  // Merge with operations type to maintain OpenAPI type information
264
- export const openApiOperations = operationsBase as typeof operationsBase & operations
795
+ export const openApiOperations = operationsBase as typeof operationsBase & Pick<operations, keyof typeof operationsBase>
265
796
 
266
797
  export type OpenApiOperations = typeof openApiOperations
267
798
 
268
- // Dynamically generate OperationId enum from the operations keys
269
- export const OperationId = {
270
- ${operationIdContent}
271
- } satisfies Record<keyof typeof operationsBase, keyof typeof operationsBase>
799
+ // Query operations only - use with useQuery() for better autocomplete
800
+ export const QueryOperationId = {
801
+ ${queryOperationIdContent}
802
+ } satisfies Partial<Record<keyof typeof operationsBase, keyof typeof operationsBase>>
803
+
804
+ export type QueryOperationId = keyof typeof QueryOperationId
805
+
806
+ // Mutation operations only - use with useMutation() for better autocomplete
807
+ export const MutationOperationId = {
808
+ ${mutationOperationIdContent}
809
+ } satisfies Partial<Record<keyof typeof operationsBase, keyof typeof operationsBase>>
810
+
811
+ export type MutationOperationId = keyof typeof MutationOperationId
812
+
813
+ /**
814
+ * Union type of all operation IDs (queries and mutations).
815
+ * Used for generic type constraints in helper types.
816
+ * @internal
817
+ */
818
+ export type AllOperationIds = QueryOperationId | MutationOperationId
819
+
820
+ // ============================================================================
821
+ // Type-safe API Helpers - Use OpType.XXX for type-safe access with intellisense
822
+ // ============================================================================
823
+
824
+ /**
825
+ * Response data type for an API operation.
826
+ * All fields are REQUIRED - no null checks needed.
827
+ * @example
828
+ * type Response = ApiResponse<OpType.getPet>
829
+ */
830
+ export type ApiResponse<K extends AllOperationIds> = ApiResponseBase<OpenApiOperations, K>
831
+
832
+ /**
833
+ * Response data type with safe typing for unreliable backends.
834
+ * Only readonly properties are required; others may be undefined.
835
+ * @example
836
+ * type Response = ApiResponseSafe<OpType.getPet>
837
+ */
838
+ export type ApiResponseSafe<K extends AllOperationIds> = ApiResponseSafeBase<OpenApiOperations, K>
839
+
840
+ /**
841
+ * Request body type for a mutation operation.
842
+ * @example
843
+ * type Request = ApiRequest<OpType.createPet>
844
+ */
845
+ export type ApiRequest<K extends AllOperationIds> = ApiRequestBase<OpenApiOperations, K>
846
+
847
+ /**
848
+ * Path parameters type for an operation.
849
+ * @example
850
+ * type Params = ApiPathParams<OpType.getPet>
851
+ */
852
+ export type ApiPathParams<K extends AllOperationIds> = ApiPathParamsBase<OpenApiOperations, K>
853
+
854
+ /**
855
+ * Query parameters type for an operation.
856
+ * @example
857
+ * type Params = ApiQueryParams<OpType.listPets>
858
+ */
859
+ export type ApiQueryParams<K extends AllOperationIds> = ApiQueryParamsBase<OpenApiOperations, K>
860
+
861
+ // ============================================================================
862
+ // OpType namespace - enables dot notation: ApiResponse<OpType.getPet>
863
+ // ============================================================================
272
864
 
273
- // Export the type for TypeScript inference
274
- export type OperationId = keyof OpenApiOperations
865
+ /**
866
+ * Namespace that mirrors operation IDs as types.
867
+ * Enables dot notation syntax: ApiResponse<OpType.getPet>
868
+ *
869
+ * This is the idiomatic TypeScript pattern for enabling dot notation
870
+ * on type-level properties. The namespace is preferred over type aliases
871
+ * because it allows \`OpType.getPet\` instead of \`OpType['getPet']\`.
872
+ *
873
+ * @example
874
+ * type Response = ApiResponse<OpType.getPet>
875
+ * type Request = ApiRequest<OpType.createPet>
876
+ */
877
+ // eslint-disable-next-line @typescript-eslint/no-namespace
878
+ export namespace OpType {
879
+ ${opTypeContent}
880
+ }
881
+
882
+ // ============================================================================
883
+ // Pre-Computed Type Aliases - For easier DX and clearer intent
884
+ // ============================================================================
885
+
886
+ /**
887
+ * Query parameters for each query operation.
888
+ *
889
+ * Use this to get autocomplete on query parameter names and types.
890
+ *
891
+ * @example
892
+ * \`\`\`typescript
893
+ * const params: QueryParams['listPets'] = { limit: 10, status: 'available' }
894
+ * const query = api.useQuery(QueryOperationId.listPets, { queryParams: params })
895
+ * \`\`\`
896
+ */
897
+ export type QueryParams = {
898
+ ${queryParamsContent}
899
+ }
900
+
901
+ /**
902
+ * Path parameters for each mutation operation.
903
+ *
904
+ * Use this to get autocomplete on path parameter names and types.
905
+ *
906
+ * @example
907
+ * \`\`\`typescript
908
+ * const params: MutationParams['updatePet'] = { petId: '123' }
909
+ * const mutation = api.useMutation(MutationOperationId.updatePet, params)
910
+ * \`\`\`
911
+ */
912
+ export type MutationParams = {
913
+ ${mutationParamsContent}
914
+ }
915
+
916
+ /**
917
+ * Request body for each mutation operation.
918
+ *
919
+ * Use this to get autocomplete on request body properties and types.
920
+ *
921
+ * @example
922
+ * \`\`\`typescript
923
+ * const body: MutationBody['createPet'] = { name: 'Fluffy', species: 'cat' }
924
+ * await createPet.mutateAsync({ data: body })
925
+ * \`\`\`
926
+ */
927
+ export type MutationBody = {
928
+ ${mutationBodyContent}
929
+ }
930
+
931
+ // ============================================================================
932
+ // LEGACY: OperationId (auto-derived from union for backward compatibility)
933
+ // ============================================================================
934
+ // Use QueryOperationId or MutationOperationId directly for better type safety
935
+
936
+ /**
937
+ * @deprecated Use QueryOperationId or MutationOperationId instead.
938
+ * Auto-derived from their union for backward compatibility.
939
+ */
940
+ export type OperationId = AllOperationIds
941
+
942
+ /**
943
+ * @deprecated Use QueryOperationId or MutationOperationId instead.
944
+ * Auto-derived from their union for backward compatibility.
945
+ */
946
+ export const OperationId = {
947
+ ...QueryOperationId,
948
+ ...MutationOperationId,
949
+ } satisfies Record<AllOperationIds, AllOperationIds>
275
950
  `;
276
951
  }
277
- async function generateApiOperations(openapiContent, outputDir) {
952
+ async function generateApiOperations(openapiContent, outputDir, excludePrefix = '_deprecated') {
278
953
  console.log('🔨 Generating openapi-typed-operations.ts file...');
279
- const { operationIds, operationInfoMap } = parseOperationsFromSpec(openapiContent);
954
+ const { operationIds, operationInfoMap } = parseOperationsFromSpec(openapiContent, excludePrefix);
280
955
  // Generate TypeScript content
281
956
  const tsContent = generateApiOperationsContent(operationIds, operationInfoMap);
282
957
  // Write to output file
@@ -287,19 +962,29 @@ async function generateApiOperations(openapiContent, outputDir) {
287
962
  }
288
963
  function printUsage() {
289
964
  console.log(`
290
- Usage: npx @qualisero/openapi-endpoint <openapi-input> <output-directory>
965
+ Usage: npx @qualisero/openapi-endpoint <openapi-input> <output-directory> [options]
291
966
 
292
967
  Arguments:
293
968
  openapi-input Path to OpenAPI JSON file or URL to fetch it from
294
969
  output-directory Directory where generated files will be saved
295
970
 
971
+ Options:
972
+ --exclude-prefix PREFIX Exclude operations with operationId starting with PREFIX
973
+ (default: '_deprecated')
974
+ --no-exclude Disable operation exclusion (include all operations)
975
+ --help, -h Show this help message
976
+
296
977
  Examples:
297
978
  npx @qualisero/openapi-endpoint ./api/openapi.json ./src/generated
298
979
  npx @qualisero/openapi-endpoint https://api.example.com/openapi.json ./src/api
980
+ npx @qualisero/openapi-endpoint ./api.json ./src/gen --exclude-prefix _internal
981
+ npx @qualisero/openapi-endpoint ./api.json ./src/gen --no-exclude
299
982
 
300
983
  This command will generate:
301
- - openapi-types.ts (TypeScript types from OpenAPI spec)
984
+ - openapi-types.ts (TypeScript types from OpenAPI spec)
302
985
  - openapi-typed-operations.ts (Operation IDs and info for use with this library)
986
+ - api-enums.ts (Type-safe enum objects from OpenAPI spec)
987
+ - api-schemas.ts (Type aliases for schema objects from OpenAPI spec)
303
988
  `);
304
989
  }
305
990
  async function main() {
@@ -308,26 +993,56 @@ async function main() {
308
993
  printUsage();
309
994
  process.exit(0);
310
995
  }
311
- if (args.length !== 2) {
312
- console.error('❌ Error: Exactly 2 arguments are required');
996
+ if (args.length < 2) {
997
+ console.error('❌ Error: At least 2 arguments are required');
313
998
  printUsage();
314
999
  process.exit(1);
315
1000
  }
316
- const [openapiInput, outputDir] = args;
1001
+ const [openapiInput, outputDir, ...optionArgs] = args;
1002
+ // Parse options
1003
+ let excludePrefix = '_deprecated'; // default
1004
+ for (let i = 0; i < optionArgs.length; i++) {
1005
+ if (optionArgs[i] === '--no-exclude') {
1006
+ excludePrefix = null;
1007
+ }
1008
+ else if (optionArgs[i] === '--exclude-prefix') {
1009
+ if (i + 1 < optionArgs.length) {
1010
+ excludePrefix = optionArgs[i + 1];
1011
+ i++; // Skip next arg since we consumed it
1012
+ }
1013
+ else {
1014
+ console.error('❌ Error: --exclude-prefix requires a value');
1015
+ printUsage();
1016
+ process.exit(1);
1017
+ }
1018
+ }
1019
+ }
317
1020
  try {
318
1021
  // Ensure output directory exists
319
1022
  if (!fs.existsSync(outputDir)) {
320
1023
  fs.mkdirSync(outputDir, { recursive: true });
321
1024
  console.log(`📁 Created output directory: ${outputDir}`);
322
1025
  }
1026
+ // Log exclusion settings
1027
+ if (excludePrefix) {
1028
+ console.log(`🚫 Excluding operations with operationId prefix: '${excludePrefix}'`);
1029
+ }
1030
+ else {
1031
+ console.log(`✅ Including all operations (no exclusion filter)`);
1032
+ }
323
1033
  // Fetch OpenAPI spec content
324
1034
  let openapiContent = await fetchOpenAPISpec(openapiInput);
325
1035
  // Parse spec and add missing operationIds
326
1036
  const openApiSpec = JSON.parse(openapiContent);
327
1037
  addMissingOperationIds(openApiSpec);
328
1038
  openapiContent = JSON.stringify(openApiSpec, null, 2);
329
- // Generate both files
330
- await Promise.all([generateTypes(openapiContent, outputDir), generateApiOperations(openapiContent, outputDir)]);
1039
+ // Generate all files
1040
+ await Promise.all([
1041
+ generateTypes(openapiContent, outputDir),
1042
+ generateApiOperations(openapiContent, outputDir, excludePrefix),
1043
+ generateApiEnums(openapiContent, outputDir, excludePrefix),
1044
+ generateApiSchemas(openapiContent, outputDir, excludePrefix),
1045
+ ]);
331
1046
  console.log('🎉 Code generation completed successfully!');
332
1047
  }
333
1048
  catch (error) {