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
37 changes: 37 additions & 0 deletions src/commands/generate/types/__tests__/management-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ describe('management-api', () => {
referencedApiEndpoint: undefined,
customFieldCreatedAt: undefined,
customFieldCreatedAtList: undefined,
selectItems: undefined,
},
{
fieldId: 'related',
Expand All @@ -135,6 +136,7 @@ describe('management-api', () => {
referencedApiEndpoint: 'articles',
customFieldCreatedAt: undefined,
customFieldCreatedAtList: undefined,
selectItems: undefined,
},
]);
expect(schema.customFields).toEqual([
Expand All @@ -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' }));

Expand Down
261 changes: 261 additions & 0 deletions src/commands/generate/types/__tests__/type-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
expect(source).toContain('widget?: Record<string, unknown>;');
// extension → Record<string, unknown>
expect(source).toContain('plugin?: Record<string, unknown>;');
// custom → custom type
expect(source).toContain('hero?: FullTestHeroBannerCustomField;');
// repeater → Array<union of custom types>
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<FullTestSchema>;',
);
// 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<T> {');
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', {
Expand Down
21 changes: 21 additions & 0 deletions src/commands/generate/types/management-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ async function fetchFromManagementApi(url: string, apiKey: string): Promise<unkn
return response.json();
}

function parseSelectItems(rawItems: unknown): string[] | undefined {
if (!Array.isArray(rawItems) || rawItems.length === 0) {
return undefined;
}

const values = rawItems
.map((item) => {
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;
Expand All @@ -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),
};
}

Expand Down
19 changes: 14 additions & 5 deletions src/commands/generate/types/type-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>';
Expand Down
Loading