diff --git a/packages/openapi-generator/src/codec.ts b/packages/openapi-generator/src/codec.ts index 05229eb6..175abbd4 100644 --- a/packages/openapi-generator/src/codec.ts +++ b/packages/openapi-generator/src/codec.ts @@ -54,12 +54,37 @@ function codecIdentifier( } else if (id.type === 'MemberExpression') { const object = id.object; if (object.type !== 'Identifier') { - if (object.type === 'MemberExpression') + if (object.type === 'MemberExpression') { + // Handle double-nested member expressions + if ( + object.object.type === 'Identifier' && + object.property.type === 'Identifier' && + object.property.value === 'keys' + ) { + // Handle `Type.keys.propertyName` format + if (id.property.type === 'Identifier') { + return E.right({ + type: 'string', + enum: [id.property.value], + }); + } + // Handle `Type.keys["Property Name"]` format (computed property) + else if ( + id.property.type === 'Computed' && + id.property.expression.type === 'StringLiteral' + ) { + return E.right({ + type: 'string', + enum: [id.property.expression.value], + }); + } + } + return errorLeft( - `Object ${ - ((object as swc.MemberExpression) && { value: String }).value - } is deeply nested, which is unsupported`, + `Nested member expressions are not supported (${object.object.type}.${object.property.type}.${id.property.type})`, ); + } + return errorLeft(`Unimplemented object type ${object.type}`); } diff --git a/packages/openapi-generator/test/nestedMemberExpression.test.ts b/packages/openapi-generator/test/nestedMemberExpression.test.ts new file mode 100644 index 00000000..725ebba9 --- /dev/null +++ b/packages/openapi-generator/test/nestedMemberExpression.test.ts @@ -0,0 +1,82 @@ +import * as E from 'fp-ts/lib/Either'; +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { TestProject } from './testProject'; +import { parsePlainInitializer, type Schema } from '../src'; + +async function testCase( + description: string, + src: string, + expected: Record, + expectedErrors: string[] = [], +) { + test(description, async () => { + const project = new TestProject({ '/index.ts': src }); + await project.parseEntryPoint('/index.ts'); + const sourceFile = project.get('/index.ts'); + if (sourceFile === undefined) { + throw new Error('Source file not found'); + } + + const actual: Record = {}; + const errors: string[] = []; + for (const symbol of sourceFile.symbols.declarations) { + if (symbol.init !== undefined) { + const result = parsePlainInitializer(project, sourceFile, symbol.init); + if (E.isLeft(result)) { + // Only keep the error message, not the stack trace + const errorMessage = result.left.split('\n')[0] ?? ''; + errors.push(errorMessage); + } else { + if (symbol.comment !== undefined) { + result.right.comment = symbol.comment; + } + actual[symbol.name] = result.right; + } + } + } + + assert.deepEqual(errors, expectedErrors); + assert.deepEqual(actual, expected); + }); +} + +const MINIMAL_NESTED_MEMBER_EXPRESSION = ` +import * as t from 'io-ts'; + +export const colorType = { + red: 'red', +} as const; + +export const ColorType = t.keyof(colorType); + +export const redItem = t.type({ + type: t.literal(ColorType.keys.red), +}); +`; + +testCase( + 'nested member expression is parsed correctly', + MINIMAL_NESTED_MEMBER_EXPRESSION, + { + colorType: { + type: 'object', + properties: { + red: { type: 'string', enum: ['red'] }, + }, + required: ['red'], + }, + ColorType: { + type: 'union', + schemas: [{ type: 'string', enum: ['red'] }], + }, + redItem: { + type: 'object', + properties: { + type: { type: 'string', enum: ['red'] }, + }, + required: ['type'], + }, + }, +);