diff --git a/src/commands/generate/types/__tests__/command.test.ts b/src/commands/generate/types/__tests__/command.test.ts index b88d799..bd69e22 100644 --- a/src/commands/generate/types/__tests__/command.test.ts +++ b/src/commands/generate/types/__tests__/command.test.ts @@ -7,20 +7,13 @@ import type { ManagementApiSchema, ManagementClientConfig, } from '../types.js'; +import { createSchema } from './helpers.js'; const FIXTURE_CONFIG: ManagementClientConfig = { serviceDomain: 'test-service', apiKey: 'test-key', }; -function createSchema(overrides: Partial = {}): ManagementApiSchema { - return { - apiFields: [], - customFields: [], - ...overrides, - }; -} - function createHarness( overrides: { resolveOutputFilePath?: (outputOption?: string) => string; diff --git a/src/commands/generate/types/__tests__/helpers.ts b/src/commands/generate/types/__tests__/helpers.ts new file mode 100644 index 0000000..a65681b --- /dev/null +++ b/src/commands/generate/types/__tests__/helpers.ts @@ -0,0 +1,20 @@ +import type { GenerationTarget, ManagementApiSchema } from '../types.js'; + +export function createSchema(overrides: Partial = {}): ManagementApiSchema { + return { + apiFields: [], + customFields: [], + ...overrides, + }; +} + +export function createTarget( + endpoint: string, + overrides: Partial = {}, +): GenerationTarget { + return { + endpoint, + schema: createSchema(), + ...overrides, + }; +} diff --git a/src/commands/generate/types/__tests__/type-generator.test.ts b/src/commands/generate/types/__tests__/type-generator.test.ts index a6ea931..f5d724c 100644 --- a/src/commands/generate/types/__tests__/type-generator.test.ts +++ b/src/commands/generate/types/__tests__/type-generator.test.ts @@ -1,25 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { renderDefinitionsFile } from '../type-generator.js'; -import type { GenerationTarget, ManagementApiSchema } from '../types.js'; - -function createSchema(overrides: Partial = {}): ManagementApiSchema { - return { - apiFields: [], - customFields: [], - ...overrides, - }; -} - -function createTarget( - endpoint: string, - overrides: Partial = {}, -): GenerationTarget { - return { - endpoint, - schema: createSchema(), - ...overrides, - }; -} +import { createSchema, createTarget } from './helpers.js'; describe('renderDefinitionsFile', () => { it('endpoint 名の PascalCase が衝突しても型名を一意化する', () => { diff --git a/src/commands/generate/types/command.ts b/src/commands/generate/types/command.ts index 6648b37..e9d8a47 100644 --- a/src/commands/generate/types/command.ts +++ b/src/commands/generate/types/command.ts @@ -1,8 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { toErrorMessage } from '../../../utils/index.js'; import { resolveConfig, resolveOutputFilePath, resolveSingleEndpoint } from './config.js'; import { fetchApiList, fetchApiSchema } from './management-api.js'; -import { toErrorMessage } from './shared.js'; import { renderDefinitionsFile } from './type-generator.js'; import type { ApiListItem, diff --git a/src/commands/generate/types/config.ts b/src/commands/generate/types/config.ts index 2cf32b0..f2f6c96 100644 --- a/src/commands/generate/types/config.ts +++ b/src/commands/generate/types/config.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { DEFAULT_OUTPUT_FILE_NAME, DEFAULT_OUTPUT_PATH } from './constants.js'; -import { toStringValue } from './shared.js'; +import { toStringValue } from '../../../utils/index.js'; import type { GenTypesOptions, ManagementClientConfig } from './types.js'; function isDeclarationFilePath(filePath: string): boolean { @@ -18,27 +18,30 @@ export function resolveOutputFilePath(outputOption?: string): string { return path.join(resolvedPath, DEFAULT_OUTPUT_FILE_NAME); } -export function resolveConfig(options: GenTypesOptions): ManagementClientConfig { - const serviceDomain = - toStringValue(options.serviceDomain) ?? toStringValue(process.env.MICROCMS_SERVICE_DOMAIN); - if (!serviceDomain) { - const envDir = process.env.__MICROCMS_CLI_ENV_DIR ?? process.cwd(); - throw new Error( - 'MICROCMS_SERVICE_DOMAIN is required. You can also pass --service-domain.\n' + - `(Searched for .env / .env.local in: ${envDir}. CWD: ${process.cwd()})`, - ); - } - - const apiKey = toStringValue(options.apiKey) ?? toStringValue(process.env.MICROCMS_API_KEY); - if (!apiKey) { - const envDir = process.env.__MICROCMS_CLI_ENV_DIR ?? process.cwd(); - throw new Error( - 'MICROCMS_API_KEY is required. You can also pass --api-key.\n' + - `(Searched for .env / .env.local in: ${envDir}. CWD: ${process.cwd()})`, - ); - } +function resolveRequiredConfig( + optionValue: string | undefined, + envKey: string, + cliFlag: string, +): string { + const value = toStringValue(optionValue) ?? toStringValue(process.env[envKey]); + if (value) return value; + + const envDir = process.env.__MICROCMS_CLI_ENV_DIR ?? process.cwd(); + throw new Error( + `${envKey} is required. You can also pass ${cliFlag}.\n` + + `(Searched for .env / .env.local in: ${envDir}. CWD: ${process.cwd()})`, + ); +} - return { serviceDomain, apiKey }; +export function resolveConfig(options: GenTypesOptions): ManagementClientConfig { + return { + serviceDomain: resolveRequiredConfig( + options.serviceDomain, + 'MICROCMS_SERVICE_DOMAIN', + '--service-domain', + ), + apiKey: resolveRequiredConfig(options.apiKey, 'MICROCMS_API_KEY', '--api-key'), + }; } export function resolveSingleEndpoint(endpointId: string | undefined): string { diff --git a/src/commands/generate/types/management-api.ts b/src/commands/generate/types/management-api.ts index 1ef53e6..5338c6b 100644 --- a/src/commands/generate/types/management-api.ts +++ b/src/commands/generate/types/management-api.ts @@ -1,5 +1,5 @@ import { MANAGEMENT_API_BASE_DOMAIN } from './constants.js'; -import { isRecord, toBooleanValue, toStringArray, toStringValue } from './shared.js'; +import { isRecord, parseArray, toBooleanValue, toStringArray, toStringValue } from '../../../utils/index.js'; import type { ApiListItem, ManagementApiField, @@ -90,12 +90,6 @@ function parseCustomField(rawCustomField: unknown): ManagementCustomField | null const createdAt = toStringValue(rawCustomField.createdAt); const fieldId = toStringValue(rawCustomField.fieldId); - const fields = Array.isArray(rawCustomField.fields) - ? rawCustomField.fields - .map((field) => parseApiField(field)) - .filter((field): field is ManagementApiField => field !== null) - : []; - if (!createdAt || !fieldId) { return null; } @@ -103,7 +97,7 @@ function parseCustomField(rawCustomField: unknown): ManagementCustomField | null return { createdAt, fieldId, - fields, + fields: parseArray(rawCustomField.fields, parseApiField), }; } @@ -112,21 +106,9 @@ function parseApiSchema(rawSchema: unknown): ManagementApiSchema { throw new Error('Management API schema response is invalid.'); } - const apiFields = Array.isArray(rawSchema.apiFields) - ? rawSchema.apiFields - .map((field) => parseApiField(field)) - .filter((field): field is ManagementApiField => field !== null) - : []; - - const customFields = Array.isArray(rawSchema.customFields) - ? rawSchema.customFields - .map((field) => parseCustomField(field)) - .filter((field): field is ManagementCustomField => field !== null) - : []; - return { - apiFields, - customFields, + apiFields: parseArray(rawSchema.apiFields, parseApiField), + customFields: parseArray(rawSchema.customFields, parseCustomField), apiType: toStringValue(rawSchema.apiType), apiEndpoint: toStringValue(rawSchema.apiEndpoint), apiName: toStringValue(rawSchema.apiName), @@ -165,9 +147,7 @@ function parseApiList(rawResponse: unknown): ApiListItem[] { } for (const items of candidateArrays) { - const parsedItems = items - .map((item) => parseApiListItem(item)) - .filter((item): item is ApiListItem => item !== null); + const parsedItems = parseArray(items, parseApiListItem); if (parsedItems.length > 0) { return parsedItems; } diff --git a/src/commands/generate/types/type-generator.ts b/src/commands/generate/types/type-generator.ts index 27827ab..1e09dc1 100644 --- a/src/commands/generate/types/type-generator.ts +++ b/src/commands/generate/types/type-generator.ts @@ -1,4 +1,4 @@ -import { toPascalCase, toTypePropertyName } from './shared.js'; +import { getUniqueName, toPascalCase, toTypePropertyName } from '../../../utils/index.js'; import type { GenerationTarget, ManagementApiField, ManagementCustomField } from './types.js'; interface GenerationContext { @@ -15,19 +15,6 @@ export function normalizeApiType(apiType?: string): 'LIST' | 'OBJECT' { return apiType?.toUpperCase() === 'OBJECT' ? 'OBJECT' : 'LIST'; } -function getUniqueName(baseName: string, usedNames: Set): string { - let candidate = baseName; - let suffix = 2; - - while (usedNames.has(candidate)) { - candidate = `${baseName}${suffix}`; - suffix += 1; - } - - usedNames.add(candidate); - return candidate; -} - function getUniqueTypeName(baseName: string, context: GenerationContext): string { return getUniqueName(baseName, context.usedCustomTypeNames); } diff --git a/src/utils/__tests__/error.test.ts b/src/utils/__tests__/error.test.ts new file mode 100644 index 0000000..0f5a73a --- /dev/null +++ b/src/utils/__tests__/error.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test'; +import { toErrorMessage } from '../error.js'; + +describe('toErrorMessage', () => { + it('Error オブジェクトから message を取り出す', () => { + expect(toErrorMessage(new Error('something failed'))).toBe('something failed'); + }); + + it('文字列はそのまま返す', () => { + expect(toErrorMessage('raw string error')).toBe('raw string error'); + }); + + it('数値は文字列に変換して返す', () => { + expect(toErrorMessage(42)).toBe('42'); + }); + + it('null / undefined も文字列化して返す', () => { + expect(toErrorMessage(null)).toBe('null'); + expect(toErrorMessage(undefined)).toBe('undefined'); + }); + + it('Error のサブクラスからも message を取り出す', () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + expect(toErrorMessage(new CustomError('custom'))).toBe('custom'); + }); +}); diff --git a/src/utils/__tests__/naming.test.ts b/src/utils/__tests__/naming.test.ts new file mode 100644 index 0000000..eb9481d --- /dev/null +++ b/src/utils/__tests__/naming.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'bun:test'; +import { getUniqueName, toPascalCase, toTypePropertyName } from '../naming.js'; + +describe('toPascalCase', () => { + it('ハイフン区切りの文字列を PascalCase に変換する', () => { + expect(toPascalCase('blog-post')).toBe('BlogPost'); + }); + + it('アンダースコア区切りの文字列を PascalCase に変換する', () => { + expect(toPascalCase('blog_post')).toBe('BlogPost'); + }); + + it('単一の単語の先頭を大文字にする', () => { + expect(toPascalCase('blog')).toBe('Blog'); + }); + + it('既に PascalCase の文字列はそのまま返す', () => { + expect(toPascalCase('BlogPost')).toBe('BlogPost'); + }); + + it('空文字列に対して "Generated" を返す', () => { + expect(toPascalCase('')).toBe('Generated'); + }); + + it('数字始まりの場合は先頭に "T" を付与する', () => { + expect(toPascalCase('123abc')).toBe('T123abc'); + }); + + it('記号のみの場合は "Generated" を返す', () => { + expect(toPascalCase('---')).toBe('Generated'); + }); + + it('複数の連続する区切り文字を正しく処理する', () => { + expect(toPascalCase('blog--post__item')).toBe('BlogPostItem'); + }); +}); + +describe('toTypePropertyName', () => { + it('有効な識別子はそのまま返す', () => { + expect(toTypePropertyName('fieldId')).toBe('fieldId'); + expect(toTypePropertyName('_private')).toBe('_private'); + expect(toTypePropertyName('$special')).toBe('$special'); + }); + + it('無効な識別子は JSON.stringify でクォートする', () => { + expect(toTypePropertyName('hero section')).toBe('"hero section"'); + expect(toTypePropertyName('my-field')).toBe('"my-field"'); + expect(toTypePropertyName('123start')).toBe('"123start"'); + }); +}); + +describe('getUniqueName', () => { + it('未使用の名前はそのまま返す', () => { + const used = new Set(); + expect(getUniqueName('Blog', used)).toBe('Blog'); + expect(used.has('Blog')).toBe(true); + }); + + it('既に使用されている名前には連番サフィックスを付与する', () => { + const used = new Set(['Blog']); + expect(getUniqueName('Blog', used)).toBe('Blog2'); + expect(used.has('Blog2')).toBe(true); + }); + + it('連番が衝突する場合は次の番号を試行する', () => { + const used = new Set(['Blog', 'Blog2', 'Blog3']); + expect(getUniqueName('Blog', used)).toBe('Blog4'); + }); + + it('連続呼び出しで一意性を維持する', () => { + const used = new Set(); + expect(getUniqueName('Item', used)).toBe('Item'); + expect(getUniqueName('Item', used)).toBe('Item2'); + expect(getUniqueName('Item', used)).toBe('Item3'); + }); +}); diff --git a/src/utils/__tests__/parse.test.ts b/src/utils/__tests__/parse.test.ts new file mode 100644 index 0000000..5fc61e5 --- /dev/null +++ b/src/utils/__tests__/parse.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'bun:test'; +import { isRecord, parseArray, toBooleanValue, toStringArray, toStringValue } from '../parse.js'; + +describe('isRecord', () => { + it('プレーンオブジェクトに対して true を返す', () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ key: 'value' })).toBe(true); + }); + + it('null, 配列, プリミティブに対して false を返す', () => { + expect(isRecord(null)).toBe(false); + expect(isRecord(undefined)).toBe(false); + expect(isRecord([])).toBe(false); + expect(isRecord('string')).toBe(false); + expect(isRecord(42)).toBe(false); + expect(isRecord(true)).toBe(false); + }); +}); + +describe('toStringValue', () => { + it('文字列を trim して返す', () => { + expect(toStringValue('hello')).toBe('hello'); + expect(toStringValue(' trimmed ')).toBe('trimmed'); + }); + + it('空文字列やホワイトスペースのみなら undefined を返す', () => { + expect(toStringValue('')).toBeUndefined(); + expect(toStringValue(' ')).toBeUndefined(); + }); + + it('文字列以外の値に対して undefined を返す', () => { + expect(toStringValue(42)).toBeUndefined(); + expect(toStringValue(null)).toBeUndefined(); + expect(toStringValue(undefined)).toBeUndefined(); + expect(toStringValue(true)).toBeUndefined(); + expect(toStringValue({})).toBeUndefined(); + }); +}); + +describe('toBooleanValue', () => { + it('boolean 値をそのまま返す', () => { + expect(toBooleanValue(true)).toBe(true); + expect(toBooleanValue(false)).toBe(false); + }); + + it('boolean 以外の値に対して undefined を返す', () => { + expect(toBooleanValue('true')).toBeUndefined(); + expect(toBooleanValue(1)).toBeUndefined(); + expect(toBooleanValue(null)).toBeUndefined(); + expect(toBooleanValue(undefined)).toBeUndefined(); + }); +}); + +describe('toStringArray', () => { + it('文字列の配列をフィルタリングして返す', () => { + expect(toStringArray(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); + }); + + it('非文字列要素を除外する', () => { + expect(toStringArray(['a', 42, 'b', null])).toEqual(['a', 'b']); + }); + + it('空文字列・ホワイトスペースのみの要素を除外する', () => { + expect(toStringArray(['a', '', ' ', 'b'])).toEqual(['a', 'b']); + }); + + it('有効な要素が無い場合は undefined を返す', () => { + expect(toStringArray([])).toBeUndefined(); + expect(toStringArray(['', ' '])).toBeUndefined(); + }); + + it('配列以外の値に対して undefined を返す', () => { + expect(toStringArray('not-array')).toBeUndefined(); + expect(toStringArray(null)).toBeUndefined(); + expect(toStringArray(undefined)).toBeUndefined(); + }); +}); + +describe('parseArray', () => { + it('各要素をパーサーで変換し、null を除外して返す', () => { + const parser = (item: unknown) => { + if (typeof item === 'number' && item > 0) return item * 2; + return null; + }; + + expect(parseArray([1, -1, 2, 0, 3], parser)).toEqual([2, 4, 6]); + }); + + it('配列以外の値に対して空配列を返す', () => { + expect(parseArray(null, () => null)).toEqual([]); + expect(parseArray(undefined, () => null)).toEqual([]); + expect(parseArray('string', () => null)).toEqual([]); + expect(parseArray({}, () => null)).toEqual([]); + }); + + it('全要素が null を返す場合は空配列を返す', () => { + expect(parseArray([1, 2, 3], () => null)).toEqual([]); + }); + + it('空配列に対して空配列を返す', () => { + expect(parseArray([], (x) => x as string)).toEqual([]); + }); +}); diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..56fddf6 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,3 @@ +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..7fa1f34 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export { isRecord, parseArray, toBooleanValue, toStringArray, toStringValue } from './parse.js'; +export { getUniqueName, toPascalCase, toTypePropertyName } from './naming.js'; +export { toErrorMessage } from './error.js'; diff --git a/src/utils/naming.ts b/src/utils/naming.ts new file mode 100644 index 0000000..0c5aefb --- /dev/null +++ b/src/utils/naming.ts @@ -0,0 +1,24 @@ +export function toPascalCase(value: string): string { + const chunks = value.split(/[^A-Za-z0-9]+/).filter((chunk) => chunk.length > 0); + const raw = chunks.map((chunk) => `${chunk.charAt(0).toUpperCase()}${chunk.slice(1)}`).join(''); + + const safe = raw.length > 0 ? raw : 'Generated'; + return /^[A-Za-z_$]/.test(safe) ? safe : `T${safe}`; +} + +export function toTypePropertyName(value: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) ? value : JSON.stringify(value); +} + +export function getUniqueName(baseName: string, usedNames: Set): string { + let candidate = baseName; + let suffix = 2; + + while (usedNames.has(candidate)) { + candidate = `${baseName}${suffix}`; + suffix += 1; + } + + usedNames.add(candidate); + return candidate; +} diff --git a/src/commands/generate/types/shared.ts b/src/utils/parse.ts similarity index 56% rename from src/commands/generate/types/shared.ts rename to src/utils/parse.ts index 9f5e52f..61c5dac 100644 --- a/src/commands/generate/types/shared.ts +++ b/src/utils/parse.ts @@ -26,18 +26,7 @@ export function toStringArray(value: unknown): string[] | undefined { return values.length > 0 ? values : undefined; } -export function toPascalCase(value: string): string { - const chunks = value.split(/[^A-Za-z0-9]+/).filter((chunk) => chunk.length > 0); - const raw = chunks.map((chunk) => `${chunk.charAt(0).toUpperCase()}${chunk.slice(1)}`).join(''); - - const safe = raw.length > 0 ? raw : 'Generated'; - return /^[A-Za-z_$]/.test(safe) ? safe : `T${safe}`; -} - -export function toTypePropertyName(value: string): string { - return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) ? value : JSON.stringify(value); -} - -export function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +export function parseArray(rawArray: unknown, parser: (item: unknown) => T | null): T[] { + if (!Array.isArray(rawArray)) return []; + return rawArray.map(parser).filter((item): item is T => item !== null); }