diff --git a/src/commands/generate/types/__tests__/management-api.test.ts b/src/commands/generate/types/__tests__/management-api.test.ts index 338c2ae..3ff1c21 100644 --- a/src/commands/generate/types/__tests__/management-api.test.ts +++ b/src/commands/generate/types/__tests__/management-api.test.ts @@ -126,6 +126,7 @@ describe('management-api', () => { referencedApiEndpoint: undefined, customFieldCreatedAt: undefined, customFieldCreatedAtList: undefined, + selectItems: undefined, }, { fieldId: 'related', @@ -135,6 +136,7 @@ describe('management-api', () => { referencedApiEndpoint: 'articles', customFieldCreatedAt: undefined, customFieldCreatedAtList: undefined, + selectItems: undefined, }, ]); expect(schema.customFields).toEqual([ @@ -150,12 +152,47 @@ describe('management-api', () => { referencedApiEndpoint: undefined, customFieldCreatedAt: undefined, customFieldCreatedAtList: undefined, + selectItems: undefined, }, ], }, ]); }); + it('fetchApiSchema は selectItems を文字列配列/オブジェクト配列の両形式でパースする', async () => { + setMockFetch(async () => { + return new Response( + JSON.stringify({ + apiEndpoint: 'product', + apiType: 'LIST', + apiFields: [ + { + fieldId: 'color', + kind: 'select', + required: true, + multipleSelect: false, + selectItems: ['Red', 'Blue', 'Green'], + }, + { + fieldId: 'size', + kind: 'select', + required: false, + multipleSelect: true, + selectItems: [{ value: 'S' }, { value: 'M' }, { value: 'L' }], + }, + ], + customFields: [], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + + const schema = await fetchApiSchema(FIXTURE_CONFIG, 'product'); + + expect(schema.apiFields[0]?.selectItems).toEqual(['Red', 'Blue', 'Green']); + expect(schema.apiFields[1]?.selectItems).toEqual(['S', 'M', 'L']); + }); + it('401 応答時は API キー向けの専用エラーメッセージを返す', async () => { setMockFetch(async () => new Response('invalid key', { status: 401, statusText: 'Unauthorized' })); diff --git a/src/commands/generate/types/__tests__/type-generator.test.ts b/src/commands/generate/types/__tests__/type-generator.test.ts index f737a68..a6ea931 100644 --- a/src/commands/generate/types/__tests__/type-generator.test.ts +++ b/src/commands/generate/types/__tests__/type-generator.test.ts @@ -57,6 +57,267 @@ describe('renderDefinitionsFile', () => { expect(source).not.toContain('SettingsListResponse'); }); + it('select フィールドは multipleSelect に関わらず常に string[] を生成する', () => { + const source = renderDefinitionsFile([ + createTarget('product', { + apiType: 'LIST', + schema: createSchema({ + apiFields: [ + { fieldId: 'category', kind: 'select', required: true, multipleSelect: false }, + { fieldId: 'tags', kind: 'select', required: false, multipleSelect: true }, + ], + }), + }), + ]); + + expect(source).toContain('category: string[];'); + expect(source).toContain('tags?: string[];'); + expect(source).not.toMatch(/category: string;/); + }); + + it('select フィールドに selectItems がある場合、文字列リテラルユニオンの配列型を生成する', () => { + const source = renderDefinitionsFile([ + createTarget('product', { + apiType: 'LIST', + schema: createSchema({ + apiFields: [ + { + fieldId: 'size', + kind: 'select', + required: true, + multipleSelect: false, + selectItems: ['S', 'M', 'L', 'XL'], + }, + ], + }), + }), + ]); + + expect(source).toContain('size: ("S" | "M" | "L" | "XL")[];'); + }); + + it('relationList フィールドは参照先エンドポイントの Content 型を配列で生成する', () => { + const source = renderDefinitionsFile([ + createTarget('article', { + apiType: 'LIST', + schema: createSchema({ + apiFields: [ + { + fieldId: 'relatedPosts', + kind: 'relationList', + required: false, + multipleSelect: false, + referencedApiEndpoint: 'blog', + }, + { + fieldId: 'authors', + kind: 'relationList', + required: true, + multipleSelect: false, + }, + ], + }), + }), + ]); + + expect(source).toContain('relatedPosts?: BlogContent[];'); + expect(source).toContain('authors: MicroCMSContentId[];'); + }); + + it('全フィールドタイプの型マッピングを正しく生成する', () => { + const source = renderDefinitionsFile([ + createTarget('full-test', { + apiType: 'LIST', + schema: createSchema({ + apiFields: [ + { fieldId: 'title', kind: 'text', required: true, multipleSelect: false }, + { fieldId: 'body', kind: 'textArea', required: false, multipleSelect: false }, + { fieldId: 'content', kind: 'richEditor', required: false, multipleSelect: false }, + { fieldId: 'contentV2', kind: 'richEditorV2', required: false, multipleSelect: false }, + { fieldId: 'contentOld', kind: 'richEditorOld', required: false, multipleSelect: false }, + { fieldId: 'publishDate', kind: 'date', required: false, multipleSelect: false }, + { fieldId: 'order', kind: 'number', required: false, multipleSelect: false }, + { fieldId: 'isPublished', kind: 'boolean', required: true, multipleSelect: false }, + { fieldId: 'thumbnail', kind: 'media', required: false, multipleSelect: false }, + { fieldId: 'gallery', kind: 'mediaList', required: false, multipleSelect: false }, + { fieldId: 'document', kind: 'file', required: false, multipleSelect: false }, + { + fieldId: 'category', + kind: 'relation', + required: false, + multipleSelect: false, + referencedApiEndpoint: 'categories', + }, + { + fieldId: 'relatedPosts', + kind: 'relationList', + required: false, + multipleSelect: false, + referencedApiEndpoint: 'blog', + }, + { + fieldId: 'status', + kind: 'select', + required: true, + multipleSelect: false, + selectItems: ['draft', 'published', 'archived'], + }, + { fieldId: 'tags', kind: 'select', required: false, multipleSelect: true }, + { fieldId: 'widget', kind: 'iframe', required: false, multipleSelect: false }, + { fieldId: 'plugin', kind: 'extension', required: false, multipleSelect: false }, + { + fieldId: 'hero', + kind: 'custom', + required: false, + multipleSelect: false, + customFieldCreatedAt: 'cf-hero', + }, + { + fieldId: 'sections', + kind: 'repeater', + required: false, + multipleSelect: false, + customFieldCreatedAtList: ['cf-text-block', 'cf-image-block'], + }, + { + fieldId: 'orphanRelation', + kind: 'relation', + required: false, + multipleSelect: false, + }, + { + fieldId: 'orphanRelationList', + kind: 'relationList', + required: false, + multipleSelect: false, + }, + { fieldId: 'unknownKind', kind: 'futureType', required: false, multipleSelect: false }, + ], + customFields: [ + { + createdAt: 'cf-hero', + fieldId: 'hero-banner', + fields: [ + { fieldId: 'image', kind: 'media', required: true, multipleSelect: false }, + { fieldId: 'caption', kind: 'text', required: false, multipleSelect: false }, + ], + }, + { + createdAt: 'cf-text-block', + fieldId: 'text-block', + fields: [ + { fieldId: 'heading', kind: 'text', required: true, multipleSelect: false }, + { fieldId: 'paragraph', kind: 'richEditor', required: false, multipleSelect: false }, + ], + }, + { + createdAt: 'cf-image-block', + fieldId: 'image-block', + fields: [ + { fieldId: 'image', kind: 'media', required: true, multipleSelect: false }, + { fieldId: 'alt', kind: 'text', required: false, multipleSelect: false }, + ], + }, + ], + }), + }), + ]); + + // text → string + expect(source).toContain('title: string;'); + // textArea → string (optional) + expect(source).toContain('body?: string;'); + // richEditor → string + expect(source).toContain('content?: string;'); + // richEditorV2 → string + expect(source).toContain('contentV2?: string;'); + // richEditorOld → string + expect(source).toContain('contentOld?: string;'); + // date → string + expect(source).toContain('publishDate?: string;'); + // number → number + expect(source).toContain('order?: number;'); + // boolean → boolean + expect(source).toContain('isPublished: boolean;'); + // media → MicroCMSImage + expect(source).toContain('thumbnail?: MicroCMSImage;'); + // mediaList → MicroCMSImage[] + expect(source).toContain('gallery?: MicroCMSImage[];'); + // file → MicroCMSFile + expect(source).toContain('document?: MicroCMSFile;'); + // relation (with endpoint) → XxxContent | null + expect(source).toContain('category?: CategoriesContent | null;'); + // relationList (with endpoint) → XxxContent[] + expect(source).toContain('relatedPosts?: BlogContent[];'); + // select (with selectItems) → literal union array + expect(source).toContain('status: ("draft" | "published" | "archived")[];'); + // select (without selectItems) → string[] + expect(source).toContain('tags?: string[];'); + // iframe → Record + expect(source).toContain('widget?: Record;'); + // extension → Record + expect(source).toContain('plugin?: Record;'); + // custom → custom type + expect(source).toContain('hero?: FullTestHeroBannerCustomField;'); + // repeater → Array + expect(source).toMatch(/sections\?: Array<.*TextBlock.*ImageBlock/); + // relation (without endpoint) → MicroCMSContentId | null + expect(source).toContain('orphanRelation?: MicroCMSContentId | null;'); + // relationList (without endpoint) → MicroCMSContentId[] + expect(source).toContain('orphanRelationList?: MicroCMSContentId[];'); + // unknown kind → unknown + expect(source).toContain('unknownKind?: unknown;'); + + // custom field types + expect(source).toContain('export type FullTestHeroBannerCustomField = {'); + expect(source).toContain('export type FullTestTextBlockCustomField = {'); + expect(source).toContain('export type FullTestImageBlockCustomField = {'); + + // LIST API generates ListResponse + expect(source).toContain( + 'export type FullTestListResponse = MicroCMSListResponse;', + ); + // LIST API uses MicroCMSListContent + expect(source).toContain( + 'export type FullTestContent = FullTestSchema & MicroCMSListContent;', + ); + }); + + it('共通型が microcms-js-sdk の型定義と一致する', () => { + const source = renderDefinitionsFile([createTarget('dummy', { apiType: 'LIST' })]); + + // MicroCMSContentId + expect(source).toContain('export interface MicroCMSContentId {'); + expect(source).toContain(' id: string;'); + + // MicroCMSDate + expect(source).toContain('export interface MicroCMSDate {'); + expect(source).toContain(' createdAt: string;'); + expect(source).toContain(' updatedAt: string;'); + expect(source).toContain(' publishedAt?: string;'); + expect(source).toContain(' revisedAt?: string;'); + + // MicroCMSImage + expect(source).toContain('export interface MicroCMSImage {'); + expect(source).toContain(' url: string;'); + expect(source).toContain(' width?: number;'); + expect(source).toContain(' height?: number;'); + expect(source).toContain(' alt?: string;'); + + // MicroCMSListResponse + expect(source).toContain('export interface MicroCMSListResponse {'); + expect(source).toContain(' contents: (T & MicroCMSListContent)[];'); + expect(source).toContain(' totalCount: number;'); + expect(source).toContain(' limit: number;'); + expect(source).toContain(' offset: number;'); + + // MicroCMSListContent / MicroCMSObjectContent + expect(source).toContain( + 'export type MicroCMSListContent = MicroCMSContentId & MicroCMSDate;', + ); + expect(source).toContain('export type MicroCMSObjectContent = MicroCMSDate;'); + }); + it('custom/repeater の不正・未知データを安全にフォールバックして型生成する', () => { const source = renderDefinitionsFile([ createTarget('page', { diff --git a/src/commands/generate/types/management-api.ts b/src/commands/generate/types/management-api.ts index 88a91d1..1ef53e6 100644 --- a/src/commands/generate/types/management-api.ts +++ b/src/commands/generate/types/management-api.ts @@ -40,6 +40,26 @@ async function fetchFromManagementApi(url: string, apiKey: string): Promise { + if (typeof item === 'string') { + return item; + } + if (isRecord(item) && typeof item.value === 'string') { + return item.value; + } + return undefined; + }) + .filter((item): item is string => item !== undefined); + + return values.length > 0 ? values : undefined; +} + function parseApiField(rawField: unknown): ManagementApiField | null { if (!isRecord(rawField)) { return null; @@ -59,6 +79,7 @@ function parseApiField(rawField: unknown): ManagementApiField | null { referencedApiEndpoint: toStringValue(rawField.referencedApiEndpoint), customFieldCreatedAt: toStringValue(rawField.customFieldCreatedAt), customFieldCreatedAtList: toStringArray(rawField.customFieldCreatedAtList), + selectItems: parseSelectItems(rawField.selectItems), }; } diff --git a/src/commands/generate/types/type-generator.ts b/src/commands/generate/types/type-generator.ts index 87ced8f..27827ab 100644 --- a/src/commands/generate/types/type-generator.ts +++ b/src/commands/generate/types/type-generator.ts @@ -106,14 +106,23 @@ function resolveFieldType(field: ManagementApiField, context: GenerationContext) return 'MicroCMSImage[]'; case 'file': return 'MicroCMSFile'; - case 'relation': - return field.referencedApiEndpoint + case 'relation': { + const refType = field.referencedApiEndpoint ? `${toPascalCase(field.referencedApiEndpoint)}Content` : 'MicroCMSContentId'; + return `${refType} | null`; + } case 'relationList': - return 'MicroCMSContentId[]'; - case 'select': - return field.multipleSelect ? 'string[]' : 'string'; + return field.referencedApiEndpoint + ? `${toPascalCase(field.referencedApiEndpoint)}Content[]` + : 'MicroCMSContentId[]'; + case 'select': { + if (field.selectItems && field.selectItems.length > 0) { + const union = field.selectItems.map((item) => JSON.stringify(item)).join(' | '); + return `(${union})[]`; + } + return 'string[]'; + } case 'iframe': case 'extension': return 'Record'; diff --git a/src/commands/generate/types/types.ts b/src/commands/generate/types/types.ts index 17f824b..20b1c3c 100644 --- a/src/commands/generate/types/types.ts +++ b/src/commands/generate/types/types.ts @@ -24,6 +24,7 @@ export interface ManagementApiField { referencedApiEndpoint?: string; customFieldCreatedAt?: string; customFieldCreatedAtList?: string[]; + selectItems?: string[]; } export interface ManagementCustomField {