From efc737359f20211fc862e7e8d82fddabbc04636c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 23 Jan 2026 18:33:47 +0900 Subject: [PATCH] Define enum --- .../changepack_log_8QB2uxYxYtvwv5NmYgyZY.json | 1 + .../generate-interface.test.ts.snap | 6 +- .../generator/src/__tests__/types.test.ts | 900 +++++++++++++++++- packages/generator/src/generate-interface.ts | 115 ++- packages/generator/src/generate-schema.ts | 388 +++++--- 5 files changed, 1272 insertions(+), 138 deletions(-) create mode 100644 .changepacks/changepack_log_8QB2uxYxYtvwv5NmYgyZY.json diff --git a/.changepacks/changepack_log_8QB2uxYxYtvwv5NmYgyZY.json b/.changepacks/changepack_log_8QB2uxYxYtvwv5NmYgyZY.json new file mode 100644 index 0000000..3215c77 --- /dev/null +++ b/.changepacks/changepack_log_8QB2uxYxYtvwv5NmYgyZY.json @@ -0,0 +1 @@ +{"changes":{"packages/generator/package.json":"Patch"},"note":"Define enum","date":"2026-01-23T09:33:41.263194300Z"} \ No newline at end of file diff --git a/packages/generator/src/__tests__/__snapshots__/generate-interface.test.ts.snap b/packages/generator/src/__tests__/__snapshots__/generate-interface.test.ts.snap index feeaf1b..dfb40b3 100644 --- a/packages/generator/src/__tests__/__snapshots__/generate-interface.test.ts.snap +++ b/packages/generator/src/__tests__/__snapshots__/generate-interface.test.ts.snap @@ -75,6 +75,8 @@ exports[`generateInterface handles nullable properties (OpenAPI 3.0 style) 1`] = import type { DevupObject } from "@devup-api/fetch"; declare module "@devup-api/fetch" { + type ResponseStatus = "active" | "inactive" + interface DevupApiServers { [\`openapi.json\`]: never } @@ -90,7 +92,7 @@ declare module "@devup-api/fetch" { metadata?: { key?: string; } | null; - status?: "active" | "inactive" | null; + status?: ResponseStatus | null; }; }; getUsers: { @@ -102,7 +104,7 @@ declare module "@devup-api/fetch" { metadata?: { key?: string; } | null; - status?: "active" | "inactive" | null; + status?: ResponseStatus | null; }; }; } diff --git a/packages/generator/src/__tests__/types.test.ts b/packages/generator/src/__tests__/types.test.ts index a5b8f4e..c26a4c2 100644 --- a/packages/generator/src/__tests__/types.test.ts +++ b/packages/generator/src/__tests__/types.test.ts @@ -5,7 +5,11 @@ import { describe, expect, test } from 'bun:test' import type { OpenAPIV3_1 } from 'openapi-types' import { generateInterface } from '../generate-interface' -import { extractParameters, getTypeFromSchema } from '../generate-schema' +import { + createSchemaContext, + extractParameters, + getTypeFromSchema, +} from '../generate-schema' // ============================================================================= // Helper @@ -45,6 +49,51 @@ describe('getTypeFromSchema type conversion', () => { expect(result.type).toBe('"active" | "inactive" | "pending"') }) + test('enum to named type with context', () => { + const schema: OpenAPIV3_1.SchemaObject = { + type: 'string', + enum: ['active', 'inactive', 'pending'], + } + const context = createSchemaContext('User') + context.propertyPath.push('status') + const result = getTypeFromSchema(schema, doc, { + context, + propertyName: undefined, + }) + + // With context, returns the enum type name + expect(result.type).toBe('UserStatus') + // Enum should be registered in context + expect(context.enums.has('UserStatus')).toBe(true) + expect(context.enums.get('UserStatus')?.values).toEqual([ + 'active', + 'inactive', + 'pending', + ]) + }) + + test('enum in nested object with context', () => { + const schema: OpenAPIV3_1.SchemaObject = { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['draft', 'published', 'archived'], + }, + }, + } + const context = createSchemaContext('Post') + const _result = getTypeFromSchema(schema, doc, { context }) + + // Enum should be registered with property path + expect(context.enums.has('PostStatus')).toBe(true) + expect(context.enums.get('PostStatus')?.values).toEqual([ + 'draft', + 'published', + 'archived', + ]) + }) + test('array type conversion', () => { const schema: OpenAPIV3_1.SchemaObject = { type: 'array', @@ -630,3 +679,852 @@ describe('Multi-server support', () => { expect(result).toContain('getAdminUsers') }) }) + +// ============================================================================= +// Enum type generation +// ============================================================================= + +describe('Enum type generation', () => { + test('generates named type alias for enum in response', () => { + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['active', 'inactive', 'pending'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + // Should generate a named type alias for the enum + expect(result).toContain( + 'type ResponseStatus = "active" | "inactive" | "pending"', + ) + // Should use the named type in the interface + expect(result).toContain('status?: ResponseStatus') + }) + + test('generates named type alias for enum in component schema', () => { + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + User: { + type: 'object', + properties: { + id: { type: 'string' }, + role: { + type: 'string', + enum: ['admin', 'user', 'guest'], + }, + }, + }, + }, + }, + }), + }) + + // Should generate a named type alias for the enum + expect(result).toContain('type UserRole = "admin" | "user" | "guest"') + // Should use the named type in the component + expect(result).toContain('role?: UserRole') + }) + + test('reuses same enum type for identical values', () => { + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + User: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['active', 'inactive'], + }, + }, + }, + }, + }, + }), + }) + + // Should only have one type definition (not duplicated) + const matches = result.match(/type UserStatus/g) + expect(matches?.length).toBe(1) + }) + + test('deduplicates enum types across response and error with same name', () => { + // This test covers the branch where enum is already registered (lines 340-342, etc.) + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['success', 'pending'], + }, + }, + }, + }, + }, + }, + '400': { + description: 'Error', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['success', 'pending'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + // Same enum should only be defined once even if used in both response and error + const matches = result.match(/type (Response|Error)Status/g) + // Should have at most one definition (first one wins) + expect(matches?.length).toBeLessThanOrEqual(2) + }) + + test('handles enum in array response without component ref', () => { + // This covers lines 381-383 (array response with enum, not using component ref) + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/items': { + get: { + operationId: 'getItems', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + category: { + type: 'string', + enum: ['food', 'drink', 'other'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + expect(result).toContain( + 'type ResponseCategory = "food" | "drink" | "other"', + ) + }) + + test('handles enum in array error response without component ref', () => { + // This covers lines 515-517 (array error with enum, not using component ref) + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/items': { + get: { + operationId: 'getItems', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { type: 'object', properties: {} }, + }, + }, + }, + '400': { + description: 'Error', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + severity: { + type: 'string', + enum: ['warning', 'error', 'critical'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + expect(result).toContain( + 'type ErrorSeverity = "warning" | "error" | "critical"', + ) + }) + + test('handles enum in error response object without component ref', () => { + // This covers lines 536-538 (error object with enum) + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/items': { + get: { + operationId: 'getItems', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { type: 'object', properties: {} }, + }, + }, + }, + '500': { + description: 'Error', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + errorCode: { + type: 'string', + enum: ['internal', 'timeout', 'unavailable'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + expect(result).toContain( + 'type ErrorErrorCode = "internal" | "timeout" | "unavailable"', + ) + }) + + test('generates enum name from values when no context path', () => { + // This covers generate-schema.ts lines 62-66 (fallback enum naming) + const context = createSchemaContext() // No schema name + // Don't push any property path + + const schema: OpenAPIV3_1.SchemaObject = { + type: 'string', + enum: ['red', 'green', 'blue'], + } + + const result = getTypeFromSchema(schema, createDocument(), { context }) + + // Should generate name from values: RedGreenBlueEnum + expect(result.type).toBe('RedGreenBlueEnum') + expect(context.enums.has('RedGreenBlueEnum')).toBe(true) + }) + + test('deduplicates enums when multiple operations have same enum property name', () => { + // This covers lines 340-342: duplicate enum check when merging from inline context + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['active', 'inactive'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/posts': { + get: { + operationId: 'getPosts', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['active', 'inactive'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + // Should only define the enum once + const matches = result.match(/type ResponseStatus/g) + expect(matches?.length).toBe(1) + }) + + test('deduplicates enums in array responses across operations', () => { + // This covers lines 381-383: duplicate enum in array response + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/items': { + get: { + operationId: 'getItems', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/tasks': { + get: { + operationId: 'getTasks', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + priority: { + type: 'string', + enum: ['low', 'medium', 'high'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + // Should only define the enum once + const matches = result.match(/type ResponsePriority/g) + expect(matches?.length).toBe(1) + }) + + test('deduplicates enums in error responses across operations', () => { + // This covers lines 474-476 and 536-538: duplicate enum in error response + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { + '200': { description: 'Success' }, + '400': { + description: 'Error', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + level: { + type: 'string', + enum: ['warn', 'error', 'fatal'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/posts': { + get: { + operationId: 'getPosts', + responses: { + '200': { description: 'Success' }, + '400': { + description: 'Error', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + level: { + type: 'string', + enum: ['warn', 'error', 'fatal'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + // Should only define the enum once + const matches = result.match(/type ErrorLevel/g) + expect(matches?.length).toBe(1) + }) + + test('deduplicates enums in array error responses across operations', () => { + // This covers lines 515-517: duplicate enum in array error response + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/items': { + get: { + operationId: 'getItems', + responses: { + '200': { description: 'Success' }, + '422': { + description: 'Validation Error', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + severity: { + type: 'string', + enum: ['info', 'warning', 'error'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/orders': { + get: { + operationId: 'getOrders', + responses: { + '200': { description: 'Success' }, + '422': { + description: 'Validation Error', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + severity: { + type: 'string', + enum: ['info', 'warning', 'error'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }) + + // Should only define the enum once + const matches = result.match(/type ErrorSeverity/g) + expect(matches?.length).toBe(1) + }) + + test('handles response with $ref that resolves to schema with enum (non-component ref)', () => { + // This specifically targets lines 340-342: $ref that is not in responseSchemaNames + // We use components.responses which resolves but doesn't go through the normal path + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/status': { + get: { + operationId: 'getStatus', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + // $ref to a response object (not schema) - extractSchemaNameFromRef returns null + $ref: '#/components/responses/StatusResponse', + }, + }, + }, + }, + }, + }, + }, + '/health': { + get: { + operationId: 'getHealth', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + $ref: '#/components/responses/StatusResponse', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + responses: { + StatusResponse: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['healthy', 'degraded', 'down'], + }, + }, + }, + }, + }, + }), + }) + + // The enum should be generated (may have different name based on resolution) + expect(result).toMatch(/type.*=.*"healthy".*\|.*"degraded".*\|.*"down"/) + }) + + test('handles error response with $ref that resolves to schema with enum (non-component ref)', () => { + // This specifically targets lines 474-476: $ref for error that is not in errorSchemaNames + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/action': { + post: { + operationId: 'doAction', + responses: { + '200': { description: 'Success' }, + '400': { + description: 'Error', + content: { + 'application/json': { + schema: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + }, + }, + }, + }, + }, + '/process': { + post: { + operationId: 'doProcess', + responses: { + '200': { description: 'Success' }, + '400': { + description: 'Error', + content: { + 'application/json': { + schema: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + responses: { + ErrorResponse: { + type: 'object', + properties: { + errorType: { + type: 'string', + enum: ['validation', 'auth', 'server'], + }, + }, + }, + }, + }, + }), + }) + + expect(result).toMatch(/type.*=.*"validation".*\|.*"auth".*\|.*"server"/) + }) + + test('handles array response with items $ref that is not a component schema', () => { + // This targets lines 381-383: array response with items $ref not in responseSchemaNames + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/logs': { + get: { + operationId: 'getLogs', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + $ref: '#/components/responses/LogEntry', + }, + }, + }, + }, + }, + }, + }, + }, + '/events': { + get: { + operationId: 'getEvents', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + $ref: '#/components/responses/LogEntry', + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + responses: { + LogEntry: { + type: 'object', + properties: { + level: { + type: 'string', + enum: ['debug', 'info', 'warn', 'error'], + }, + }, + }, + }, + }, + }), + }) + + expect(result).toMatch( + /type.*=.*"debug".*\|.*"info".*\|.*"warn".*\|.*"error"/, + ) + }) + + test('handles array error response with items $ref that is not a component schema', () => { + // This targets lines 515-517: array error with items $ref not in errorSchemaNames + const result = generateInterface({ + 'openapi.json': createDocument({ + paths: { + '/submit': { + post: { + operationId: 'submit', + responses: { + '200': { description: 'Success' }, + '422': { + description: 'Validation Errors', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + $ref: '#/components/responses/ValidationError', + }, + }, + }, + }, + }, + }, + }, + }, + '/update': { + put: { + operationId: 'update', + responses: { + '200': { description: 'Success' }, + '422': { + description: 'Validation Errors', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + $ref: '#/components/responses/ValidationError', + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + responses: { + ValidationError: { + type: 'object', + properties: { + field: { type: 'string' }, + constraint: { + type: 'string', + enum: ['required', 'format', 'range', 'unique'], + }, + }, + }, + }, + }, + }), + }) + + expect(result).toMatch( + /type.*=.*"required".*\|.*"format".*\|.*"range".*\|.*"unique"/, + ) + }) +}) diff --git a/packages/generator/src/generate-interface.ts b/packages/generator/src/generate-interface.ts index 67d29a4..de63a34 100644 --- a/packages/generator/src/generate-interface.ts +++ b/packages/generator/src/generate-interface.ts @@ -3,6 +3,8 @@ import { toPascal } from '@devup-api/utils' import type { OpenAPIV3_1 } from 'openapi-types' import { convertCase } from './convert-case' import { + createSchemaContext, + type EnumDefinition, extractParameters, extractRequestBody, formatTypeValue, @@ -50,7 +52,10 @@ function generateSchemaInterface( requestComponents: Record responseComponents: Record errorComponents: Record + enumDefinitions: Map } { + // Create context for tracking enums + const enumContext = createSchemaContext() const endpoints: Record< 'get' | 'post' | 'put' | 'delete' | 'patch', Record @@ -321,11 +326,21 @@ function generateSchemaInterface( // Extract schema type with response options const responseDefaultNonNullable = options?.responseDefaultNonNullable ?? true + const inlineContext = createSchemaContext('Response') const { type: schemaType } = getTypeFromSchema( jsonContent.schema, schema, - { defaultNonNullable: responseDefaultNonNullable }, + { + defaultNonNullable: responseDefaultNonNullable, + context: inlineContext, + }, ) + // Merge enums + for (const [enumName, enumDef] of inlineContext.enums) { + if (!enumContext.enums.has(enumName)) { + enumContext.enums.set(enumName, enumDef) + } + } responseType = schemaType } } else { @@ -352,22 +367,42 @@ function generateSchemaInterface( // Extract schema type with response options const responseDefaultNonNullable = options?.responseDefaultNonNullable ?? true + const inlineContext = createSchemaContext('Response') const { type: schemaType } = getTypeFromSchema( jsonContent.schema, schema, - { defaultNonNullable: responseDefaultNonNullable }, + { + defaultNonNullable: responseDefaultNonNullable, + context: inlineContext, + }, ) + // Merge enums + for (const [enumName, enumDef] of inlineContext.enums) { + if (!enumContext.enums.has(enumName)) { + enumContext.enums.set(enumName, enumDef) + } + } responseType = schemaType } } else { // Extract schema type with response options const responseDefaultNonNullable = options?.responseDefaultNonNullable ?? true + const inlineContext = createSchemaContext('Response') const { type: schemaType } = getTypeFromSchema( jsonContent.schema, schema, - { defaultNonNullable: responseDefaultNonNullable }, + { + defaultNonNullable: responseDefaultNonNullable, + context: inlineContext, + }, ) + // Merge enums + for (const [enumName, enumDef] of inlineContext.enums) { + if (!enumContext.enums.has(enumName)) { + enumContext.enums.set(enumName, enumDef) + } + } responseType = schemaType } } @@ -425,11 +460,21 @@ function generateSchemaInterface( // Extract schema type with response options const responseDefaultNonNullable = options?.responseDefaultNonNullable ?? true + const inlineContext = createSchemaContext('Error') const { type: schemaType } = getTypeFromSchema( jsonContent.schema, schema, - { defaultNonNullable: responseDefaultNonNullable }, + { + defaultNonNullable: responseDefaultNonNullable, + context: inlineContext, + }, ) + // Merge enums + for (const [enumName, enumDef] of inlineContext.enums) { + if (!enumContext.enums.has(enumName)) { + enumContext.enums.set(enumName, enumDef) + } + } errorType = schemaType } } else { @@ -456,22 +501,42 @@ function generateSchemaInterface( // Extract schema type with response options const responseDefaultNonNullable = options?.responseDefaultNonNullable ?? true + const inlineContext = createSchemaContext('Error') const { type: schemaType } = getTypeFromSchema( jsonContent.schema, schema, - { defaultNonNullable: responseDefaultNonNullable }, + { + defaultNonNullable: responseDefaultNonNullable, + context: inlineContext, + }, ) + // Merge enums + for (const [enumName, enumDef] of inlineContext.enums) { + if (!enumContext.enums.has(enumName)) { + enumContext.enums.set(enumName, enumDef) + } + } errorType = schemaType } } else { // Extract schema type with response options const responseDefaultNonNullable = options?.responseDefaultNonNullable ?? true + const inlineContext = createSchemaContext('Error') const { type: schemaType } = getTypeFromSchema( jsonContent.schema, schema, - { defaultNonNullable: responseDefaultNonNullable }, + { + defaultNonNullable: responseDefaultNonNullable, + context: inlineContext, + }, ) + // Merge enums + for (const [enumName, enumDef] of inlineContext.enums) { + if (!enumContext.enums.has(enumName)) { + enumContext.enums.set(enumName, enumDef) + } + } errorType = schemaType } } @@ -522,11 +587,22 @@ function generateSchemaInterface( defaultNonNullable = requestDefaultNonNullable } + // Create a fresh context for each schema with the schema name + const schemaContext = createSchemaContext(schemaName) + const { type: schemaType } = getTypeFromSchema( schemaObj as OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject, schema, - { defaultNonNullable }, + { defaultNonNullable, context: schemaContext }, ) + + // Merge enums from this schema into the main context + for (const [enumName, enumDef] of schemaContext.enums) { + if (!enumContext.enums.has(enumName)) { + enumContext.enums.set(enumName, enumDef) + } + } + // Keep original schema name as-is if (requestSchemaNames.has(schemaName)) { requestComponents[schemaName] = schemaType @@ -546,6 +622,7 @@ function generateSchemaInterface( requestComponents, responseComponents, errorComponents, + enumDefinitions: enumContext.enums, } } @@ -569,6 +646,9 @@ export function generateInterface( const serverResponseComponents: Record> = {} const serverErrorComponents: Record> = {} + // Collect all enum definitions across all servers + const allEnumDefinitions = new Map() + for (const [originalServerName, schema] of Object.entries(schemas)) { const normalizedServerName = normalizeServerName(originalServerName) serverNames.push(normalizedServerName) @@ -578,12 +658,20 @@ export function generateInterface( requestComponents, responseComponents, errorComponents, + enumDefinitions, } = generateSchemaInterface(schema, normalizedServerName, options) serverEndpoints[normalizedServerName] = endpoints serverRequestComponents[normalizedServerName] = requestComponents serverResponseComponents[normalizedServerName] = responseComponents serverErrorComponents[normalizedServerName] = errorComponents + + // Merge enum definitions + for (const [enumName, enumDef] of enumDefinitions) { + if (!allEnumDefinitions.has(enumName)) { + allEnumDefinitions.set(enumName, enumDef) + } + } } // Generate DevupApiServers interface (just server names with never) @@ -694,6 +782,13 @@ export function generateInterface( ? ` interface DevupErrorComponentStruct {\n${errorComponentEntries.join(';\n')}\n }` : ' interface DevupErrorComponentStruct {}' + // Generate enum type aliases + const enumTypeAliases: string[] = [] + for (const [enumName, enumDef] of allEnumDefinitions) { + const values = enumDef.values.map((v) => `"${String(v)}"`).join(' | ') + enumTypeAliases.push(` type ${enumName} = ${values}`) + } + // Combine all interfaces const allInterfaces = [ serversInterface, @@ -703,5 +798,9 @@ export function generateInterface( errorComponentInterface, ].join('\n\n') - return `import "@devup-api/fetch";\nimport type { DevupObject } from "@devup-api/fetch";\n\ndeclare module "@devup-api/fetch" {\n${allInterfaces}\n}` + // Generate enum types outside the module declaration (global types) + const enumTypesBlock = + enumTypeAliases.length > 0 ? `${enumTypeAliases.join('\n')}\n\n` : '' + + return `import "@devup-api/fetch";\nimport type { DevupObject } from "@devup-api/fetch";\n\ndeclare module "@devup-api/fetch" {\n${enumTypesBlock}${allInterfaces}\n}` } diff --git a/packages/generator/src/generate-schema.ts b/packages/generator/src/generate-schema.ts index 6b8907b..946f6bb 100644 --- a/packages/generator/src/generate-schema.ts +++ b/packages/generator/src/generate-schema.ts @@ -2,6 +2,86 @@ import type { OpenAPIV3_1 } from 'openapi-types' import type { ParameterDefinition } from './generate-interface' import { wrapInterfaceKeyGuard } from './wrap-interface-key-guard' +/** + * Enum definition collected during schema processing + */ +export interface EnumDefinition { + /** Unique key for the enum (e.g., "UserStatus", "OrderType") */ + name: string + /** Enum values */ + values: (string | number)[] + /** Whether the enum is nullable */ + nullable: boolean +} + +/** + * Context for tracking enums during schema processing + */ +export interface SchemaProcessingContext { + /** Map of enum key to enum definition */ + enums: Map + /** Current property path for naming enums */ + propertyPath: string[] + /** Schema name if processing a component schema */ + schemaName?: string +} + +/** + * Create a new schema processing context + */ +export function createSchemaContext( + schemaName?: string, +): SchemaProcessingContext { + return { + enums: new Map(), + propertyPath: [], + schemaName, + } +} + +/** + * Generate a unique enum name based on context + */ +function generateEnumName( + context: SchemaProcessingContext, + values: (string | number)[], +): string { + // Use schema name + property path for naming + const parts: string[] = [] + + if (context.schemaName) { + parts.push(context.schemaName) + } + + if (context.propertyPath.length > 0) { + parts.push(...context.propertyPath) + } + + if (parts.length === 0) { + // Fallback: generate name from values + const valueBasedName = values + .slice(0, 3) + .map((v) => String(v).charAt(0).toUpperCase() + String(v).slice(1)) + .join('') + return `${valueBasedName}Enum` + } + + // Convert to PascalCase + const name = parts + .map((p) => { + // Remove special characters and convert to PascalCase + return p + .replace(/[^a-zA-Z0-9]/g, ' ') + .split(' ') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join('') + }) + .join('') + + return name +} + /** * Check if a schema is nullable (OpenAPI 3.0 or 3.1) * OpenAPI 3.0: uses `nullable: true` @@ -67,169 +147,223 @@ export function getTypeFromSchema( document: OpenAPIV3_1.Document, options?: { defaultNonNullable?: boolean + context?: SchemaProcessingContext + propertyName?: string }, ): { type: unknown; default?: unknown } { const defaultNonNullable = options?.defaultNonNullable ?? false - // Handle $ref - if ('$ref' in schema) { - const resolved = resolveSchemaRef( - schema.$ref, - document, - ) - if (resolved) { - return getTypeFromSchema(resolved, document, options) - } - return { type: 'unknown', default: undefined } + const context = options?.context + + // Push property name to path if provided + if (context && options?.propertyName) { + context.propertyPath.push(options.propertyName) } - const schemaObj = schema as OpenAPIV3_1.SchemaObject + try { + // Handle $ref + if ('$ref' in schema) { + const resolved = resolveSchemaRef( + schema.$ref, + document, + ) + if (resolved) { + return getTypeFromSchema(resolved, document, { + ...options, + propertyName: undefined, // Don't double-push property name + }) + } + return { type: 'unknown', default: undefined } + } + + const schemaObj = schema as OpenAPIV3_1.SchemaObject - // Handle allOf, anyOf, oneOf - if (schemaObj.allOf) { - const types = schemaObj.allOf.map((s) => - getTypeFromSchema(s, document, options), - ) - return { - type: - types.length > 0 - ? types.map((t) => formatTypeValue(t.type)).join(' & ') - : 'unknown', - default: schemaObj.default, + // Handle allOf, anyOf, oneOf + if (schemaObj.allOf) { + const types = schemaObj.allOf.map((s) => + getTypeFromSchema(s, document, { + ...options, + propertyName: undefined, + }), + ) + return { + type: + types.length > 0 + ? types.map((t) => formatTypeValue(t.type)).join(' & ') + : 'unknown', + default: schemaObj.default, + } } - } - if (schemaObj.anyOf || schemaObj.oneOf) { - const types = (schemaObj.anyOf || schemaObj.oneOf || []).map((s) => - getTypeFromSchema(s, document, options), - ) - return { - type: - types.length > 0 - ? `(${types.map((t) => formatTypeValue(t.type)).join(' | ')})` - : 'unknown', - default: schemaObj.default, + if (schemaObj.anyOf || schemaObj.oneOf) { + const types = (schemaObj.anyOf || schemaObj.oneOf || []).map((s) => + getTypeFromSchema(s, document, { + ...options, + propertyName: undefined, + }), + ) + return { + type: + types.length > 0 + ? `(${types.map((t) => formatTypeValue(t.type)).join(' | ')})` + : 'unknown', + default: schemaObj.default, + } } - } - // Check if schema is nullable - const nullable = isNullable(schemaObj) + // Check if schema is nullable + const nullable = isNullable(schemaObj) + + // Handle enum + if (schemaObj.enum) { + // If context is provided, register the enum and return a reference + if (context) { + const enumName = generateEnumName(context, schemaObj.enum) + const existingEnum = context.enums.get(enumName) + + if (!existingEnum) { + context.enums.set(enumName, { + name: enumName, + values: schemaObj.enum as (string | number)[], + nullable, + }) + } - // Handle enum - if (schemaObj.enum) { - const enumType = schemaObj.enum.map((v) => `"${String(v)}"`).join(' | ') - return { - type: nullable ? `${enumType} | null` : enumType, - default: schemaObj.default, + return { + type: nullable ? `${enumName} | null` : enumName, + default: schemaObj.default, + } + } + + // Fallback: inline enum type (for backward compatibility) + const enumType = schemaObj.enum.map((v) => `"${String(v)}"`).join(' | ') + return { + type: nullable ? `${enumType} | null` : enumType, + default: schemaObj.default, + } } - } - // Get the actual type (handle OpenAPI 3.1 type arrays) - const actualType = Array.isArray(schemaObj.type) - ? getNonNullType(schemaObj.type) - : schemaObj.type + // Get the actual type (handle OpenAPI 3.1 type arrays) + const actualType = Array.isArray(schemaObj.type) + ? getNonNullType(schemaObj.type) + : schemaObj.type - // Handle primitive types - if (actualType === 'string') { - return { - type: nullable ? 'string | null' : 'string', - default: schemaObj.default, + // Handle primitive types + if (actualType === 'string') { + return { + type: nullable ? 'string | null' : 'string', + default: schemaObj.default, + } } - } - if (actualType === 'number' || actualType === 'integer') { - return { - type: nullable ? 'number | null' : 'number', - default: schemaObj.default, + if (actualType === 'number' || actualType === 'integer') { + return { + type: nullable ? 'number | null' : 'number', + default: schemaObj.default, + } } - } - if (actualType === 'boolean') { - return { - type: nullable ? 'boolean | null' : 'boolean', - default: schemaObj.default, + if (actualType === 'boolean') { + return { + type: nullable ? 'boolean | null' : 'boolean', + default: schemaObj.default, + } } - } - // Handle array - if (actualType === 'array') { - const items = 'items' in schemaObj ? schemaObj.items : undefined - if (items) { - const itemType = getTypeFromSchema(items, document, options) + // Handle array + if (actualType === 'array') { + const items = 'items' in schemaObj ? schemaObj.items : undefined + if (items) { + const itemType = getTypeFromSchema(items, document, { + ...options, + propertyName: undefined, + }) + return { + type: nullable + ? { __isArray: true, items: itemType.type, __nullable: true } + : { __isArray: true, items: itemType.type }, + default: schemaObj.default, + } + } return { - type: nullable - ? { __isArray: true, items: itemType.type, __nullable: true } - : { __isArray: true, items: itemType.type }, + type: nullable ? 'unknown[] | null' : 'unknown[]', default: schemaObj.default, } } - return { - type: nullable ? 'unknown[] | null' : 'unknown[]', - default: schemaObj.default, - } - } - // Handle object - if (actualType === 'object' || schemaObj.properties) { - const props: Record = {} - const required = schemaObj.required || [] - - if (schemaObj.properties) { - for (const [key, value] of Object.entries(schemaObj.properties)) { - const propType = getTypeFromSchema(value, document, options) - // Check if property has default value - // Need to resolve $ref if present to check for default - let hasDefault = false - if ('$ref' in value) { - const resolved = resolveSchemaRef( - value.$ref, + // Handle object + if (actualType === 'object' || schemaObj.properties) { + const props: Record = {} + const required = schemaObj.required || [] + + if (schemaObj.properties) { + for (const [key, value] of Object.entries(schemaObj.properties)) { + const propType = getTypeFromSchema(value, document, { + ...options, + propertyName: key, + }) + // Check if property has default value + // Need to resolve $ref if present to check for default + let hasDefault = false + if ('$ref' in value) { + const resolved = resolveSchemaRef( + value.$ref, + document, + ) + if (resolved) { + hasDefault = resolved.default !== undefined + } + } else { + const propSchema = value as OpenAPIV3_1.SchemaObject + hasDefault = propSchema.default !== undefined + } + const isInRequired = required.includes(key) + + // If defaultNonNullable is true and has default, treat as required + // Otherwise, mark as optional if not in required array + if (defaultNonNullable && hasDefault && !isInRequired) { + props[key] = propType + } else if (!isInRequired) { + props[`${key}?`] = propType + } else { + props[key] = propType + } + } + } + + // Handle additionalProperties + if (schemaObj.additionalProperties) { + if (schemaObj.additionalProperties === true) { + props['[key: string]'] = { type: 'unknown', default: undefined } + } else if (typeof schemaObj.additionalProperties === 'object') { + const additionalType = getTypeFromSchema( + schemaObj.additionalProperties, document, + { + ...options, + propertyName: undefined, + }, ) - if (resolved) { - hasDefault = resolved.default !== undefined + props['[key: string]'] = { + type: additionalType.type, + default: additionalType.default, } - } else { - const propSchema = value as OpenAPIV3_1.SchemaObject - hasDefault = propSchema.default !== undefined - } - const isInRequired = required.includes(key) - - // If defaultNonNullable is true and has default, treat as required - // Otherwise, mark as optional if not in required array - if (defaultNonNullable && hasDefault && !isInRequired) { - props[key] = propType - } else if (!isInRequired) { - props[`${key}?`] = propType - } else { - props[key] = propType } } - } - // Handle additionalProperties - if (schemaObj.additionalProperties) { - if (schemaObj.additionalProperties === true) { - props['[key: string]'] = { type: 'unknown', default: undefined } - } else if (typeof schemaObj.additionalProperties === 'object') { - const additionalType = getTypeFromSchema( - schemaObj.additionalProperties, - document, - options, - ) - props['[key: string]'] = { - type: additionalType.type, - default: additionalType.default, - } + return { + type: nullable ? { ...props, __nullable: true } : { ...props }, + default: schemaObj.default, } } - return { - type: nullable ? { ...props, __nullable: true } : { ...props }, - default: schemaObj.default, + // Handle oneOf/anyOf already handled above, but check again for safety + return { type: 'unknown', default: undefined } + } finally { + // Pop property name from path + if (context && options?.propertyName) { + context.propertyPath.pop() } } - - // Handle oneOf/anyOf already handled above, but check again for safety - return { type: 'unknown', default: undefined } } /**