Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface OpenAPISpec {
interface OpenAPISchema {
type?: string
enum?: (string | number)[]
$ref?: string
properties?: Record<string, OpenAPISchema>
items?: OpenAPISchema
[key: string]: unknown
Expand Down Expand Up @@ -476,30 +477,50 @@ 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.
*/
function extractEnumsFromSpec(openApiSpec: OpenAPISpec): EnumInfo[] {
const enums: EnumInfo[] = []
const seenEnumValues = new Map<string, string>() // 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<string, (string | number)[]> = 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)
Expand Down Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions tests/fixtures/toy-openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
"type": "integer",
"maximum": 100
}
},
{
"name": "status",
"in": "query",
"schema": {
"$ref": "#/components/schemas/PetStatus"
}
}
],
"responses": {
Expand Down Expand Up @@ -510,6 +517,10 @@
},
"components": {
"schemas": {
"PetStatus": {
"type": "string",
"enum": ["available", "pending", "adopted"]
},
"Pet": {
"type": "object",
"required": ["name"],
Expand All @@ -526,8 +537,7 @@
"type": "string"
},
"status": {
"type": "string",
"enum": ["available", "pending", "adopted"]
"$ref": "#/components/schemas/PetStatus"
}
}
},
Expand All @@ -542,8 +552,7 @@
"type": "string"
},
"status": {
"type": "string",
"enum": ["available", "pending", "adopted"]
"$ref": "#/components/schemas/PetStatus"
}
}
},
Expand Down
128 changes: 122 additions & 6 deletions tests/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper returns early when components.schemas is missing, which means it will never extract enums that exist only in operation parameters (inline param.schema.enum). The real CLI implementation can still extract inline parameter enums without schemas, so this helper no longer accurately mirrors CLI behavior. Consider removing the early return and letting schemaEnumLookup be empty when schemas are absent.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: removed early return and updated test to verify parameter enum extraction via sourcePath


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<string, (string | number)[]> = 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())

Expand All @@ -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
}
Expand Down Expand Up @@ -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', () => {
Expand Down