From d57e3aebb41bcd70298eed2050a674152c533250 Mon Sep 17 00:00:00 2001 From: Dave dV Date: Thu, 26 Feb 2026 17:40:26 +0000 Subject: [PATCH 1/2] fix: extract enums from -referenced schemas - Add PetStatus schema component to toy-openapi.json - Update Pet/NewPet status properties to use to PetStatus - Add status query parameter to listPets operation - Update extractEnumsFromSpec helper to support resolution - Remove inline test data, use actual fixture instead - Remove redundant test case Fixes issue where CLI only extracted inline enums, not -referenced enums. --- src/cli.ts | 53 ++++++++----- tests/fixtures/toy-openapi.json | 17 ++++- tests/unit/cli.test.ts | 128 ++++++++++++++++++++++++++++++-- 3 files changed, 170 insertions(+), 28 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3d2a4c6..108eec0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,6 +32,7 @@ interface OpenAPISpec { interface OpenAPISchema { type?: string enum?: (string | number)[] + $ref?: string properties?: Record items?: OpenAPISchema [key: string]: unknown @@ -476,7 +477,7 @@ function addEnumIfUnique( /** * Extracts all enums from an OpenAPI spec. * Walks through: - * 1. components.schemas and their properties + * 1. components.schemas and their properties (inline enum or $ref to enum schema) * 2. Operation parameters (query, header, path, cookie) * Deduplicates by comparing enum value sets. */ @@ -484,22 +485,42 @@ function extractEnumsFromSpec(openApiSpec: OpenAPISpec): EnumInfo[] { const enums: EnumInfo[] = [] const seenEnumValues = new Map() // Maps JSON stringified values -> enum name (for deduplication) + // Build lookup of schemas that ARE enums (have enum property on the schema itself) + const schemaEnumLookup: Map = new Map() + if (openApiSpec.components?.schemas) { + for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) { + if (schema.enum && Array.isArray(schema.enum)) { + const enumValues = (schema.enum as (string | number | null)[]).filter((v) => v !== null) as (string | number)[] + if (enumValues.length > 0) { + schemaEnumLookup.set(schemaName, enumValues) + } + } + } + } + + // Helper to resolve enum values from a schema (inline or $ref) + function resolveEnumValues(schema: OpenAPISchema): (string | number)[] | null { + // Inline enum + if (schema.enum && Array.isArray(schema.enum)) { + const enumValues = (schema.enum as (string | number | null)[]).filter((v) => v !== null) as (string | number)[] + return enumValues.length > 0 ? enumValues : null + } + // $ref to an enum schema + if (typeof schema.$ref === 'string') { + const refName = schema.$ref.split('/').pop()! + return schemaEnumLookup.get(refName) ?? null + } + return null + } + // Extract from components.schemas if (openApiSpec.components?.schemas) { for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) { if (!schema.properties) continue for (const [propName, propSchema] of Object.entries(schema.properties)) { - if (!propSchema.enum || !Array.isArray(propSchema.enum)) continue - - // Filter out null values from enum array - const enumValues = (propSchema.enum as (string | number | null)[]).filter((v) => v !== null) as ( - | string - | number - )[] - - // Skip if all values were null - if (enumValues.length === 0) continue + const enumValues = resolveEnumValues(propSchema) + if (!enumValues) continue // Use schema name as-is (already PascalCase), convert property name from snake_case const enumName = schemaName + toPascalCase(propName) @@ -533,14 +554,10 @@ function extractEnumsFromSpec(openApiSpec: OpenAPISpec): EnumInfo[] { const paramIn = paramObj.in as string | undefined const paramSchema = paramObj.schema as OpenAPISchema | undefined - if (!paramName || !paramIn || !paramSchema?.enum) continue - - const enumValues = (paramSchema.enum as (string | number | null)[]).filter((v) => v !== null) as ( - | string - | number - )[] + if (!paramName || !paramIn || !paramSchema) continue - if (enumValues.length === 0) continue + const enumValues = resolveEnumValues(paramSchema) + if (!enumValues) continue // Create a descriptive name: OperationName + ParamName const operationName = op.operationId diff --git a/tests/fixtures/toy-openapi.json b/tests/fixtures/toy-openapi.json index 7abe79c..1241ea8 100644 --- a/tests/fixtures/toy-openapi.json +++ b/tests/fixtures/toy-openapi.json @@ -24,6 +24,13 @@ "type": "integer", "maximum": 100 } + }, + { + "name": "status", + "in": "query", + "schema": { + "$ref": "#/components/schemas/PetStatus" + } } ], "responses": { @@ -510,6 +517,10 @@ }, "components": { "schemas": { + "PetStatus": { + "type": "string", + "enum": ["available", "pending", "adopted"] + }, "Pet": { "type": "object", "required": ["name"], @@ -526,8 +537,7 @@ "type": "string" }, "status": { - "type": "string", - "enum": ["available", "pending", "adopted"] + "$ref": "#/components/schemas/PetStatus" } } }, @@ -542,8 +552,7 @@ "type": "string" }, "status": { - "type": "string", - "enum": ["available", "pending", "adopted"] + "$ref": "#/components/schemas/PetStatus" } } }, diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index e1bc631..f16fc18 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -640,21 +640,70 @@ export type OperationId = keyof OpenApiOperations if (!openApiSpec.components?.schemas) { return enums } + const toCase = (str: string, capitalize: boolean): string => { + // If already camelCase or PascalCase, just adjust first letter + if (/[a-z]/.test(str) && /[A-Z]/.test(str)) { + return capitalize ? str.charAt(0).toUpperCase() + str.slice(1) : str.charAt(0).toLowerCase() + str.slice(1) + } - const toPascalCase = (str: string) => - str + // Handle snake_case, kebab-case, spaces, etc. + const parts = str .split(/[-_\s]+/) .filter((part) => part.length > 0) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join('') + .map((part) => { + // If this part is already in camelCase, just capitalize the first letter + if (/[a-z]/.test(part) && /[A-Z]/.test(part)) { + return part.charAt(0).toUpperCase() + part.slice(1) + } + // Otherwise, capitalize and lowercase to normalize + return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() + }) + + if (parts.length === 0) return str + + // Apply capitalization rule to first part + if (!capitalize) { + parts[0] = parts[0].charAt(0).toLowerCase() + parts[0].slice(1) + } + + return parts.join('') + } + + const toPascalCase = (str: string): string => toCase(str, true) + + // Build lookup of schemas that ARE enums (have enum property on the schema itself) + const schemaEnumLookup: Map = new Map() + for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) { + if ((schema as any).enum && Array.isArray((schema as any).enum)) { + const enumValues = (schema as any).enum as (string | number)[] + if (enumValues.length > 0) { + schemaEnumLookup.set(schemaName, enumValues) + } + } + } + + // Helper to resolve enum values from a schema (inline or $ref) + function resolveEnumValues(schema: any): (string | number)[] | null { + // Inline enum + if (schema.enum && Array.isArray(schema.enum)) { + const enumValues = schema.enum as (string | number)[] + return enumValues.length > 0 ? enumValues : null + } + // $ref to an enum schema + if (typeof schema.$ref === 'string') { + const refName = schema.$ref.split('/').pop()! + return schemaEnumLookup.get(refName) ?? null + } + return null + } for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) { if (!(schema as any).properties) continue for (const [propName, propSchema] of Object.entries((schema as any).properties)) { - if (!(propSchema as any).enum) continue + const enumValues = resolveEnumValues(propSchema as any) + if (!enumValues) continue - const enumValues = (propSchema as any).enum as (string | number)[] const enumName = toPascalCase(schemaName) + toPascalCase(propName) const valuesKey = JSON.stringify([...enumValues].sort()) @@ -672,6 +721,55 @@ export type OperationId = keyof OpenApiOperations } } + // Extract from operation parameters + if (openApiSpec.paths) { + for (const [pathUrl, pathItem] of Object.entries(openApiSpec.paths)) { + for (const [method, operation] of Object.entries(pathItem)) { + const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'] + if (!httpMethods.includes(method.toLowerCase())) continue + + const op = operation as any + if (op.parameters && Array.isArray(op.parameters)) { + for (const param of op.parameters) { + const paramName = param.name as string | undefined + const paramIn = param.in as string | undefined + const paramSchema = param.schema as any | undefined + + if (!paramName || !paramIn || !paramSchema) continue + + const paramEnumValues = resolveEnumValues(paramSchema) + if (!paramEnumValues) continue + + const operationName = op.operationId + ? toPascalCase(op.operationId) + : toPascalCase(pathUrl.split('/').pop() || 'param') + const paramNamePascal = toPascalCase(paramName) + + let enumName: string + if (operationName.endsWith(paramNamePascal)) { + enumName = operationName + } else { + enumName = operationName + paramNamePascal + } + + const valuesKey = JSON.stringify([...paramEnumValues].sort()) + const existingName = seenEnumValues.get(valuesKey) + if (existingName) { + continue + } + + seenEnumValues.set(valuesKey, enumName) + enums.push({ + name: enumName, + values: paramEnumValues, + sourcePath: `paths.${pathUrl}.${method}.parameters[${paramName}]`, + }) + } + } + } + } + } + enums.sort((a, b) => a.name.localeCompare(b.name)) return enums } @@ -756,6 +854,24 @@ export type OperationId = keyof OpenApiOperations expect(enums[0].name).toBe('OrderStatus') expect(enums[0].values).toEqual(['in-progress', 'completed', 'pending_review']) }) + + it('should extract enums from $ref-referenced schemas', () => { + const enums = extractEnumsFromSpec(toyOpenApiSpec) + + const petStatusEnum = enums.find((e) => e.name === 'PetStatus') + expect(petStatusEnum).toBeDefined() + expect(petStatusEnum?.values).toEqual(['available', 'pending', 'adopted']) + }) + + it('should extract enums from $ref in operation parameters', () => { + const enums = extractEnumsFromSpec(toyOpenApiSpec) + + // The parameter enum has the same values as the schema enum, so it's deduplicated + // The primary enum name comes from schema properties (PetStatus) + const petStatusEnum = enums.find((e) => e.name === 'PetStatus') + expect(petStatusEnum).toBeDefined() + expect(petStatusEnum?.values).toEqual(['available', 'pending', 'adopted']) + }) }) describe('URL validation patterns', () => { From ea0a665ea1342a3b7aee0b98a75dc714740c3651 Mon Sep 17 00:00:00 2001 From: Dave dV Date: Fri, 27 Feb 2026 10:02:12 +0000 Subject: [PATCH 2/2] fix: extract enums from -referenced schemas - Add PetStatus schema component to toy-openapi.json - Update Pet/NewPet status properties to use to PetStatus - Add status query parameter to listPets operation - Update extractEnumsFromSpec helper to support resolution - Remove inline test data, use actual fixture instead - Remove early return to allow extraction from parameters without schemas - Add test that verifies parameter enum extraction via sourcePath Fixes issue where CLI only extracted inline enums, not -referenced enums. Bump version to 0.18.2 --- CHANGELOG.md | 6 +++ package.json | 2 +- tests/unit/cli.test.ts | 91 ++++++++++++++++++++++++++++-------------- 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eff283..f271aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.18.2] - 2026-02-26 + +### Fixed + +- Enum extraction now detects enums referenced via `$ref` to `components.schemas`, not just inline enums + ## [0.18.1] - 2026-02-26 ### Fixed diff --git a/package.json b/package.json index 5eeb9b4..3728aff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qualisero/openapi-endpoint", - "version": "0.18.1", + "version": "0.18.2", "repository": { "type": "git", "url": "https://github.com/qualisero/openapi-endpoint.git" diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index f16fc18..4365e1f 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -637,9 +637,6 @@ export type OperationId = keyof OpenApiOperations const enums: { name: string; values: (string | number)[]; sourcePath: string }[] = [] const seenEnumValues = new Map() - if (!openApiSpec.components?.schemas) { - return enums - } const toCase = (str: string, capitalize: boolean): string => { // If already camelCase or PascalCase, just adjust first letter if (/[a-z]/.test(str) && /[A-Z]/.test(str)) { @@ -673,11 +670,13 @@ export type OperationId = keyof OpenApiOperations // Build lookup of schemas that ARE enums (have enum property on the schema itself) const schemaEnumLookup: Map = new Map() - for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) { - if ((schema as any).enum && Array.isArray((schema as any).enum)) { - const enumValues = (schema as any).enum as (string | number)[] - if (enumValues.length > 0) { - schemaEnumLookup.set(schemaName, enumValues) + if (openApiSpec.components?.schemas) { + for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) { + if ((schema as any).enum && Array.isArray((schema as any).enum)) { + const enumValues = (schema as any).enum as (string | number)[] + if (enumValues.length > 0) { + schemaEnumLookup.set(schemaName, enumValues) + } } } } @@ -697,27 +696,30 @@ export type OperationId = keyof OpenApiOperations return null } - for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) { - if (!(schema as any).properties) continue + // Extract enums from schema properties + if (openApiSpec.components?.schemas) { + for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) { + if (!(schema as any).properties) continue - for (const [propName, propSchema] of Object.entries((schema as any).properties)) { - const enumValues = resolveEnumValues(propSchema as any) - if (!enumValues) continue + for (const [propName, propSchema] of Object.entries((schema as any).properties)) { + const enumValues = resolveEnumValues(propSchema as any) + if (!enumValues) continue - const enumName = toPascalCase(schemaName) + toPascalCase(propName) - const valuesKey = JSON.stringify([...enumValues].sort()) + const enumName = toPascalCase(schemaName) + toPascalCase(propName) + const valuesKey = JSON.stringify([...enumValues].sort()) - const existingName = seenEnumValues.get(valuesKey) - if (existingName) { - continue - } + const existingName = seenEnumValues.get(valuesKey) + if (existingName) { + continue + } - seenEnumValues.set(valuesKey, enumName) - enums.push({ - name: enumName, - values: enumValues, - sourcePath: `components.schemas.${schemaName}.properties.${propName}`, - }) + seenEnumValues.set(valuesKey, enumName) + enums.push({ + name: enumName, + values: enumValues, + sourcePath: `components.schemas.${schemaName}.properties.${propName}`, + }) + } } } @@ -864,13 +866,40 @@ export type OperationId = keyof OpenApiOperations }) it('should extract enums from $ref in operation parameters', () => { - const enums = extractEnumsFromSpec(toyOpenApiSpec) + const specWithParamRefOnly = { + openapi: '3.0.0', + paths: { + '/pets': { + get: { + operationId: 'listPets', + parameters: [ + { + name: 'status', + in: 'query', + schema: { $ref: '#/components/schemas/PetStatus' }, + }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + components: { + schemas: { + PetStatus: { + type: 'string', + enum: ['available', 'pending', 'adopted'], + }, + }, + }, + } - // The parameter enum has the same values as the schema enum, so it's deduplicated - // The primary enum name comes from schema properties (PetStatus) - const petStatusEnum = enums.find((e) => e.name === 'PetStatus') - expect(petStatusEnum).toBeDefined() - expect(petStatusEnum?.values).toEqual(['available', 'pending', 'adopted']) + const enums = extractEnumsFromSpec(specWithParamRefOnly) + + // Should extract enum from parameter, with sourcePath pointing to the parameter + expect(enums).toHaveLength(1) + expect(enums[0].name).toBe('ListPetsStatus') + expect(enums[0].values).toEqual(['available', 'pending', 'adopted']) + expect(enums[0].sourcePath).toBe('paths./pets.get.parameters[status]') }) })