import {
LoginRequest,
LoginResponse,
LoginResponseSchema,
User,
UserSchema,
UpdateUserRequest,
Blog,
BlogSchema,
BlogListResponseSchema,
CreateBlogRequest,
UpdateBlogRequest,
ArticleWithMetadata,
ArticleWithMetadataSchema,
ArticleListResponseSchema,
CreateArticleRequest,
UpdateArticleRequest,
ListArticlesQuery,
ApiErrorSchema,
} from './types.js';
import { PlumeApiError, PlumeErrorType } from './errors.js';
/**
* リトライ設定
*/
export interface RetryConfig {
/** 最大リトライ回数 (デフォルト: 3) */
maxRetries?: number;
/** 基本リトライ間隔 (ミリ秒, デフォルト: 200) */
retryDelay?: number;
/** リトライ対象のHTTPステータスコード (デフォルト: [500, 502, 503, 504, 429]) */
retryOn?: number[];
/** エクスポネンシャルバックオフの指数倍率 (デフォルト: 2) */
backoffMultiplier?: number;
/** 最大リトライ遅延時間 (ミリ秒, デフォルト: 10000) */
maxRetryDelay?: number;
/** ジッター有効化 (デフォルト: true) */
jitter?: boolean;
}
/**
* VergeApiClient設定オプション
*/
export interface VergeApiClientConfig {
/** APIベースURL */
baseUrl: string;
/** カスタムfetch関数 (デフォルト: global.fetch) */
fetchFn?: typeof fetch;
/** タイムアウト時間 (ミリ秒, デフォルト: なし) */
timeout?: number;
/** リトライ設定 */
retry?: RetryConfig;
/** 動作モード (デフォルト: 'production') */
mode?: 'mock' | 'production';
}
/**
* Plume API クライアント
* 認証、記事管理などのAPIエンドポイントを提供
*/
export class VergeApiClient {
private baseUrl: string;
private token: string | null = null;
private config: {
baseUrl: string;
fetchFn: typeof fetch;
timeout?: number;
retry: Required<RetryConfig>;
mode: 'mock' | 'production';
};
constructor(configOrBaseUrl: string | VergeApiClientConfig) {
// 下位互換性: stringの場合はbaseUrlとして扱う
const config: VergeApiClientConfig =
typeof configOrBaseUrl === 'string'
? { baseUrl: configOrBaseUrl }
: configOrBaseUrl;
// デフォルト値を設定
this.config = {
baseUrl: config.baseUrl.replace(/\/$/, ''), // 末尾のスラッシュを削除
fetchFn: config.fetchFn || fetch,
timeout: config.timeout,
retry: {
maxRetries: config.retry?.maxRetries ?? 3,
retryDelay: config.retry?.retryDelay ?? 200,
retryOn: config.retry?.retryOn ?? [500, 502, 503, 504, 429],
backoffMultiplier: config.retry?.backoffMultiplier ?? 2,
maxRetryDelay: config.retry?.maxRetryDelay ?? 10000,
jitter: config.retry?.jitter ?? true,
},
mode: config.mode || 'production',
};
this.baseUrl = this.config.baseUrl;
}
// ============================================================
// トークン管理
// ============================================================
/**
* 認証トークンを設定
*/
setToken(token: string | null): void {
this.token = token;
}
/**
* 現在の認証トークンを取得
*/
getToken(): string | null {
return this.token;
}
// ============================================================
// プライベートヘルパーメソッド
// ============================================================
/**
* 共通ヘッダーを生成
*/
private getHeaders(includeAuth: boolean = false, includeContentType: boolean = false): Record<string, string> {
const headers: Record<string, string> = {};
if (includeContentType) {
headers['Content-Type'] = 'application/json';
}
if (includeAuth) {
if (!this.token) {
throw new Error('No authentication token available');
}
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
/**
* 指定時間待機 (リトライ間隔)
*/
private async sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* リトライ遅延時間を計算 (エクスポネンシャルバックオフ + ジッター)
* @param attempt リトライ試行回数 (0から開始)
* @returns 遅延時間 (ミリ秒)
*/
private calculateRetryDelay(attempt: number): number {
const { retryDelay, backoffMultiplier, maxRetryDelay, jitter } = this.config.retry;
// エクスポネンシャルバックオフ: baseDelay * (multiplier ^ attempt)
const exponentialDelay = retryDelay * Math.pow(backoffMultiplier, attempt);
// 最大遅延時間でキャップ
const cappedDelay = Math.min(exponentialDelay, maxRetryDelay);
// ジッター適用: delay * (0.5 + random * 0.5)
// これにより、delayの50%〜100%の範囲でランダムな遅延が発生
if (jitter) {
return Math.floor(cappedDelay * (0.5 + Math.random() * 0.5));
}
return cappedDelay;
}
/**
* エラーがリトライ可能かどうかを判定
* retryOn 設定とエラーの種類を考慮
*/
private shouldRetryError(error: PlumeApiError): boolean {
// ネットワークエラー、タイムアウトエラーは常にリトライ
if (error.type === PlumeErrorType.NETWORK || error.type === PlumeErrorType.TIMEOUT) {
return true;
}
// APIエラーの場合、retryOn 設定をチェック
if (error.type === PlumeErrorType.API && error.statusCode !== undefined) {
return this.config.retry.retryOn.includes(error.statusCode);
}
return false;
}
/**
* APIリクエストを実行してレスポンスを処理 (リトライ機能付き)
*/
private async request<T>(
endpoint: string,
options: RequestInit,
schema?: { parse: (data: unknown) => T }
): Promise<T> {
let lastError: PlumeApiError | null = null;
let lastResponse: Response | undefined;
for (let attempt = 0; attempt <= this.config.retry.maxRetries; attempt++) {
// タイムアウト処理 (AbortController)
const abortController = new AbortController();
let timeoutId: NodeJS.Timeout | undefined;
if (this.config.timeout) {
timeoutId = setTimeout(() => {
abortController.abort();
}, this.config.timeout);
}
try {
// AbortSignalをオプションに追加
const fetchOptions = {
...options,
signal: abortController.signal,
};
const response = await this.config.fetchFn(
`${this.baseUrl}${endpoint}`,
fetchOptions
);
lastResponse = response;
// タイムアウトタイマーをクリア
if (timeoutId) {
clearTimeout(timeoutId);
}
// 204 No Content の場合はボディなし
if (response.status === 204) {
return undefined as T;
}
const data = await response.json();
if (!response.ok) {
// エラーレスポンスをパース
// 生のデータからエラーメッセージを直接抽出
const errorMessage =
data && typeof data === 'object' && 'error' in data
? String(data.error)
: `API error: ${response.status} ${response.statusText}`;
lastError = new PlumeApiError({
type: PlumeErrorType.API,
message: errorMessage,
statusCode: response.status,
responseBody: data,
retryCount: attempt,
endpoint,
});
// リトライ可能なエラーかチェック
if (attempt < this.config.retry.maxRetries && this.shouldRetryError(lastError)) {
const delay = this.calculateRetryDelay(attempt);
await this.sleep(delay);
continue;
}
throw lastError;
}
// スキーマが提供されている場合はバリデーション
if (schema) {
try {
return schema.parse(data);
} catch (validationError) {
// Zodバリデーションエラーの場合、よりわかりやすいエラーメッセージを生成
const errorMessage =
validationError instanceof Error
? `Response validation failed: ${validationError.message}`
: 'Response validation failed';
throw new PlumeApiError({
type: PlumeErrorType.VALIDATION,
message: errorMessage,
statusCode: response.status,
responseBody: data,
retryCount: attempt,
endpoint,
cause: validationError instanceof Error ? validationError : undefined,
});
}
}
return data as T;
} catch (error) {
// タイムアウトタイマーをクリア
if (timeoutId) {
clearTimeout(timeoutId);
}
// PlumeApiError の場合はそのまま再throw (バリデーションエラーなど)
if (error instanceof PlumeApiError) {
throw error;
}
// ネットワークエラーやその他のエラー
if (error instanceof Error) {
// AbortErrorの場合はタイムアウトエラーとして扱う
if (error.name === 'AbortError') {
lastError = new PlumeApiError({
type: PlumeErrorType.TIMEOUT,
message: 'Request timed out',
retryCount: attempt,
endpoint,
cause: error,
});
} else {
// ネットワークエラー
lastError = new PlumeApiError({
type: PlumeErrorType.NETWORK,
message: error.message || 'Network error occurred',
retryCount: attempt,
endpoint,
cause: error,
});
}
// リトライ可能なエラーかチェック
if (attempt < this.config.retry.maxRetries && this.shouldRetryError(lastError)) {
const delay = this.calculateRetryDelay(attempt);
await this.sleep(delay);
continue;
}
throw lastError;
}
// 予期しないエラー型
lastError = new PlumeApiError({
type: PlumeErrorType.UNKNOWN,
message: 'Unknown error occurred',
retryCount: attempt,
endpoint,
});
throw lastError;
}
}
// ここに到達することはないはずだが、念のため
throw (
lastError ||
new PlumeApiError({
type: PlumeErrorType.UNKNOWN,
message: 'Unknown error occurred',
retryCount: this.config.retry.maxRetries,
endpoint,
})
);
}
/**
* クエリパラメータをURLに追加
*/
private buildUrlWithQuery(endpoint: string, query?: Record<string, string | undefined>): string {
if (!query) {
return endpoint;
}
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined) {
params.append(key, value);
}
});
const queryString = params.toString();
return queryString ? `${endpoint}?${queryString}` : endpoint;
}
// ============================================================
// 認証API
// ============================================================
/**
* ログイン
* @param email メールアドレス
* @param password パスワード
* @returns トークンとユーザー情報
*/
async login(email: string, password: string): Promise<LoginResponse> {
const requestData: LoginRequest = { email, password };
const response = await this.request<LoginResponse>(
'/api/auth/login',
{
method: 'POST',
headers: this.getHeaders(false, true),
body: JSON.stringify(requestData),
},
LoginResponseSchema
);
// ログイン成功時、トークンを自動保存
this.setToken(response.token);
return response;
}
/**
* 現在のユーザー情報を取得
* @returns ユーザー情報
*/
async getCurrentUser(): Promise<User> {
return this.request<User>(
'/api/auth/me',
{
method: 'GET',
headers: this.getHeaders(true),
},
UserSchema
);
}
/**
* ユーザー情報を更新
* @param userId ユーザーID
* @param data 更新データ
* @returns 更新されたユーザー情報
*/
async updateUser(userId: number, data: UpdateUserRequest): Promise<User> {
return this.request<User>(
`/api/users/${userId}`,
{
method: 'PUT',
headers: this.getHeaders(true, true),
body: JSON.stringify(data),
},
UserSchema
);
}
// ============================================================
// ブログ管理API
// ============================================================
/**
* ブログ一覧を取得
* @returns ブログ一覧
*/
async listBlogs(): Promise<Blog[]> {
return this.request<Blog[]>(
'/api/blogs',
{
method: 'GET',
headers: this.getHeaders(true),
},
BlogListResponseSchema
);
}
/**
* ブログ詳細を取得
* @param blogId ブログID
* @returns ブログ詳細
*/
async getBlog(blogId: number): Promise<Blog> {
return this.request<Blog>(
`/api/blogs/${blogId}`,
{
method: 'GET',
headers: this.getHeaders(true),
},
BlogSchema
);
}
/**
* ブログを作成
* @param data ブログ作成データ
* @returns 作成されたブログ
*/
async createBlog(data: CreateBlogRequest): Promise<Blog> {
return this.request<Blog>(
'/api/blogs',
{
method: 'POST',
headers: this.getHeaders(true, true),
body: JSON.stringify(data),
},
BlogSchema
);
}
/**
* ブログを更新
* @param blogId ブログID
* @param data 更新データ
* @returns 更新されたブログ
*/
async updateBlog(blogId: number, data: UpdateBlogRequest): Promise<Blog> {
return this.request<Blog>(
`/api/blogs/${blogId}`,
{
method: 'PUT',
headers: this.getHeaders(true, true),
body: JSON.stringify(data),
},
BlogSchema
);
}
/**
* ブログを削除
* @param blogId ブログID
*/
async deleteBlog(blogId: number): Promise<void> {
return this.request<void>(`/api/blogs/${blogId}`, {
method: 'DELETE',
headers: this.getHeaders(true),
});
}
// ============================================================
// 記事管理API
// ============================================================
/**
* 記事一覧を取得
* @param blogId ブログID
* @param query クエリパラメータ (検索、フィルタなど)
* @returns 記事一覧
*/
async listArticles(
blogId: number,
query?: ListArticlesQuery
): Promise<ArticleWithMetadata[]> {
const endpoint = this.buildUrlWithQuery(`/api/blogs/${blogId}/articles`, query);
return this.request<ArticleWithMetadata[]>(
endpoint,
{
method: 'GET',
headers: this.getHeaders(true),
},
ArticleListResponseSchema
);
}
/**
* 記事詳細を取得
* @param blogId ブログID
* @param articleId 記事ID
* @returns 記事詳細
*/
async getArticle(blogId: number, articleId: number): Promise<ArticleWithMetadata> {
return this.request<ArticleWithMetadata>(
`/api/blogs/${blogId}/articles/${articleId}`,
{
method: 'GET',
headers: this.getHeaders(true),
},
ArticleWithMetadataSchema
);
}
/**
* 記事を作成
* @param blogId ブログID
* @param data 記事作成データ
* @returns 作成された記事
*/
async createArticle(
blogId: number,
data: CreateArticleRequest
): Promise<ArticleWithMetadata> {
return this.request<ArticleWithMetadata>(
`/api/blogs/${blogId}/articles`,
{
method: 'POST',
headers: this.getHeaders(true, true),
body: JSON.stringify(data),
},
ArticleWithMetadataSchema
);
}
/**
* 記事を更新
* @param blogId ブログID
* @param articleId 記事ID
* @param data 更新データ
* @returns 更新された記事
*/
async updateArticle(
blogId: number,
articleId: number,
data: UpdateArticleRequest
): Promise<ArticleWithMetadata> {
return this.request<ArticleWithMetadata>(
`/api/blogs/${blogId}/articles/${articleId}`,
{
method: 'PUT',
headers: this.getHeaders(true, true),
body: JSON.stringify(data),
},
ArticleWithMetadataSchema
);
}
/**
* 記事を削除
* @param blogId ブログID
* @param articleId 記事ID
*/
async deleteArticle(blogId: number, articleId: number): Promise<void> {
return this.request<void>(`/api/blogs/${blogId}/articles/${articleId}`, {
method: 'DELETE',
headers: this.getHeaders(true),
});
}
}