Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
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
191 changes: 168 additions & 23 deletions tests/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,38 +637,138 @@ export type OperationId = keyof OpenApiOperations
const enums: { name: string; values: (string | number)[]; sourcePath: string }[] = []
const seenEnumValues = new Map<string, string>()

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()
})

for (const [schemaName, schema] of Object.entries(openApiSpec.components.schemas)) {
if (!(schema as any).properties) continue
if (parts.length === 0) return str

for (const [propName, propSchema] of Object.entries((schema as any).properties)) {
if (!(propSchema as any).enum) continue
// Apply capitalization rule to first part
if (!capitalize) {
parts[0] = parts[0].charAt(0).toLowerCase() + parts[0].slice(1)
}

return parts.join('')
}

const enumValues = (propSchema as any).enum as (string | number)[]
const enumName = toPascalCase(schemaName) + toPascalCase(propName)
const valuesKey = JSON.stringify([...enumValues].sort())
const toPascalCase = (str: string): string => toCase(str, true)

const existingName = seenEnumValues.get(valuesKey)
if (existingName) {
continue
// 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 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)
}
}
}
}

seenEnumValues.set(valuesKey, enumName)
enums.push({
name: enumName,
values: enumValues,
sourcePath: `components.schemas.${schemaName}.properties.${propName}`,
})
// 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
}

// 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

const enumName = toPascalCase(schemaName) + toPascalCase(propName)
const valuesKey = JSON.stringify([...enumValues].sort())

const existingName = seenEnumValues.get(valuesKey)
if (existingName) {
continue
}

seenEnumValues.set(valuesKey, enumName)
enums.push({
name: enumName,
values: enumValues,
sourcePath: `components.schemas.${schemaName}.properties.${propName}`,
})
}
}
}

// 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}]`,
})
}
}
}
}
}

Expand Down Expand Up @@ -756,6 +856,51 @@ 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 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'],
},
},
},
}

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]')
})
Comment thread
zedrdave marked this conversation as resolved.
})

describe('URL validation patterns', () => {
Expand Down