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
9 changes: 1 addition & 8 deletions src/commands/generate/types/__tests__/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): ManagementApiSchema {
return {
apiFields: [],
customFields: [],
...overrides,
};
}

function createHarness(
overrides: {
resolveOutputFilePath?: (outputOption?: string) => string;
Expand Down
20 changes: 20 additions & 0 deletions src/commands/generate/types/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { GenerationTarget, ManagementApiSchema } from '../types.js';

export function createSchema(overrides: Partial<ManagementApiSchema> = {}): ManagementApiSchema {
return {
apiFields: [],
customFields: [],
...overrides,
};
}

export function createTarget(
endpoint: string,
overrides: Partial<GenerationTarget> = {},
): GenerationTarget {
return {
endpoint,
schema: createSchema(),
...overrides,
};
}
21 changes: 1 addition & 20 deletions src/commands/generate/types/__tests__/type-generator.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): ManagementApiSchema {
return {
apiFields: [],
customFields: [],
...overrides,
};
}

function createTarget(
endpoint: string,
overrides: Partial<GenerationTarget> = {},
): GenerationTarget {
return {
endpoint,
schema: createSchema(),
...overrides,
};
}
import { createSchema, createTarget } from './helpers.js';

describe('renderDefinitionsFile', () => {
it('endpoint 名の PascalCase が衝突しても型名を一意化する', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/generate/types/command.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
45 changes: 24 additions & 21 deletions src/commands/generate/types/config.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down
30 changes: 5 additions & 25 deletions src/commands/generate/types/management-api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -90,20 +90,14 @@ 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;
}

return {
createdAt,
fieldId,
fields,
fields: parseArray(rawCustomField.fields, parseApiField),
};
}

Expand All @@ -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),
Expand Down Expand Up @@ -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;
}
Expand Down
15 changes: 1 addition & 14 deletions src/commands/generate/types/type-generator.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -15,19 +15,6 @@ export function normalizeApiType(apiType?: string): 'LIST' | 'OBJECT' {
return apiType?.toUpperCase() === 'OBJECT' ? 'OBJECT' : 'LIST';
}

function getUniqueName(baseName: string, usedNames: Set<string>): 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);
}
Expand Down
31 changes: 31 additions & 0 deletions src/utils/__tests__/error.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
76 changes: 76 additions & 0 deletions src/utils/__tests__/naming.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
expect(getUniqueName('Blog', used)).toBe('Blog');
expect(used.has('Blog')).toBe(true);
});

it('既に使用されている名前には連番サフィックスを付与する', () => {
const used = new Set<string>(['Blog']);
expect(getUniqueName('Blog', used)).toBe('Blog2');
expect(used.has('Blog2')).toBe(true);
});

it('連番が衝突する場合は次の番号を試行する', () => {
const used = new Set<string>(['Blog', 'Blog2', 'Blog3']);
expect(getUniqueName('Blog', used)).toBe('Blog4');
});

it('連続呼び出しで一意性を維持する', () => {
const used = new Set<string>();
expect(getUniqueName('Item', used)).toBe('Item');
expect(getUniqueName('Item', used)).toBe('Item2');
expect(getUniqueName('Item', used)).toBe('Item3');
});
});
Loading