mcpField
S
service-integration

service-integration

github.com/mgd34msu開発653 回インストール10 週間前に更新
リポジトリ

service-integration について

service-integrationは、サービス選定から検証まで、アプリケーションへの外部サービス導入を支援するスキルです。一般的なサービスの実装例に加え、エラー処理、再試行、本番APIを使わないテストを扱います。

主な機能

  1. 01サービス選定のガイド
  2. 02メール、CMS、アップロード、分析の導入パターン
  3. 03Webhookの設定と検証
  4. 04再試行とサーキットブレーカーのパターン
  5. 05モックを使った統合テスト
  6. 06検証スクリプト

活用シーン

  1. 01メールサービスやCMSの導入
  2. 02ファイルアップロードや分析の統合
  3. 03外部呼び出しの再試行と障害への対応
  4. 04本番APIを使わないWebhookのテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/mgd34msu/goodvibes-plugin --skill service-integration

インストール後、Claude Code で/service-integrationを入力して読み込まれたか確認します。

同梱ファイルreferences/service-patterns.mdscripts/validate-services.sh

詳細

リソース

scripts/
  validate-services.sh
references/
  service-patterns.md

サービス統合の実装

このスキルでは、サービスの選定から本番環境へのデプロイまで、外部サービスをアプリケーションに統合する方法を説明します。GoodVibes の高精度ツールを活用し、適切なエラー処理とテストを備えた、型安全で堅牢なサービス統合を実現します。

このスキルを使用するタイミング

次のような場合に、このスキルを使用してください。

  • メールプロバイダー(Resend、SendGrid、Postmark)を統合する
  • コンテンツ管理システム(Sanity、Contentful、Payload、Strapi)をセットアップする
  • ファイルアップロードサービス(UploadThing、Cloudinary、S3)を実装する
  • アナリティクスとトラッキング(PostHog、Plausible、Google Analytics)を追加する
  • サードパーティサービス用の Webhook エンドポイントを設定する
  • リトライロジックとサーキットブレーカーを実装する
  • 本番 API にアクセスせずにサービス統合をテストする

前提条件

必要なコンテキスト:

  • サービス要件(メール、CMS、アップロード、アナリティクス)
  • 想定されるトラフィック量と規模の要件
  • 予算の制約
  • セルフホストとマネージドサービスのどちらを希望するか

ツール:

  • 既存の統合を検出する precision_grep
  • サービス設定を分析する precision_read
  • 統合コードを作成する precision_write
  • テストと検証を行う precision_exec
  • 一括分析を行う discover

フェーズ 1:検出 — 既存の統合を検出する

一貫性を保つため、新しいサービスを追加する前に既存の統合を分析します。

ステップ 1.1:サービス SDK とパターンを検出する

discover を使用して、複数の側面を並行して分析します。

discover:
  queries:
    - id: email-sdks
      type: grep
      pattern: "(resend|sendgrid|postmark|nodemailer)"
      glob: "package.json"
    
    - id: cms-sdks
      type: grep
      pattern: "(@sanity|contentful|@payloadcms|@strapi)"
      glob: "package.json"
    
    - id: upload-sdks
      type: grep
      pattern: "(uploadthing|cloudinary|@aws-sdk/client-s3)"
      glob: "package.json"
    
    - id: analytics-sdks
      type: grep
      pattern: "(posthog-js|plausible|@vercel/analytics)"
      glob: "package.json"
    
    - id: api-keys-env
      type: grep
      pattern: "(RESEND_|SENDGRID_|SANITY_|CONTENTFUL_|UPLOADTHING_|CLOUDINARY_|AWS_|POSTHOG_)"
      glob: ".env.example"
    
    - id: service-clients
      type: glob
      patterns: ["src/lib/*client.ts", "src/services/**/*.ts", "lib/services/**/*.ts"]
    
    - id: webhook-routes
      type: glob
      patterns: ["src/app/api/webhooks/**/*.ts", "pages/api/webhooks/**/*.ts"]
  
  verbosity: files_only
ステップ 1.2:サービスクライアントのパターンを分析する

既存のサービスクライアントを読み込み、プロジェクトのパターンを把握します。

precision_read:
  files:
    - path: "src/lib/email-client.ts"
      extract: outline
    - path: "src/services/upload.ts"
      extract: symbols
  
  output:
    format: minimal

判断ポイント: 新しい統合には既存のパターンを使用してください。パターンが存在しない場合は、以下の実装ガイドに従ってください。

フェーズ 2:サービスの選定

要件、規模、予算に基づいてサービスを選択します。

メールサービスの判断ツリー

トランザクションメールの場合:

  • Resend — DX が最も優れており、React Email をサポート。1 日あたり 100 通まで無料、5 万通で月額 20 ドル
  • SendGrid — エンタープライズ向け機能を備え、1 日あたり 100 通まで無料、10 万通で月額 20 ドル
  • Postmark — 高い到達率を重視。1 万通で月額 15 ドル、無料プランなし
  • AWS SES — 大規模利用時に最も低コスト(1,000 通あたり 0.10 ドル)ですが、セットアップに手間がかかります

マーケティングメールの場合:

  • ConvertKit — クリエイター向け。登録者 1,000 人で月額 29 ドル
  • Mailchimp — オールインワンプラットフォーム。登録者 500 人まで無料
  • Loops — 開発者向け。登録者 2,000 人で月額 49 ドル

推奨: トランザクションメールには Resend から始め、月間 100 万通以上になったら SES に移行します。

CMS プラットフォームの判断ツリー

構造化コンテンツ(ブログ、ドキュメント)の場合:

  • Sanity — DX が最も優れており、リアルタイムコラボレーションに対応。無料プランも充実
  • Contentful — エンタープライズ対応で、堅牢な GraphQL を備えていますが、料金体系が複雑
  • Payload — セルフホスト可能で、TypeScript に完全対応。ベンダーロックインなし

アプリコンテンツ(製品、ユーザー生成コンテンツ)の場合:

  • Payload — 複雑なデータモデルに最適で、認証機能を内蔵
  • Strapi — 大規模なプラグインエコシステムを備え、セルフホスト可能

マーケティングページの場合:

  • Builder.io — ビジュアルエディターを備え、A/B テスト機能を内蔵
  • Sanity — 開発者向けで、Sanity Studio によるビジュアル編集に対応

推奨: コンテンツ中心のサイトには Sanity、アプリのバックエンドには Payload を使用します。

ファイルアップロードの判断ツリー

画像(プロフィール写真、製品写真)の場合:

  • UploadThing — 設定不要で、Next.js と統合可能。2GB のストレージで月額 10 ドル
  • Cloudinary — 画像変換に対応。無料プランでは帯域幅 25GB
  • Vercel Blob — エッジネットワークを利用。ストレージ 1GB あたり 0.15 ドル

大容量ファイル(動画、ドキュメント)の場合:

  • AWS S3 — 業界標準。ストレージ 1GB あたり 0.023 ドルで、大規模利用時に最も低コスト
  • Cloudflare R2 — S3 互換で、エグレス料金なし。ストレージ 1GB あたり 0.015 ドル
  • Backblaze B2 — 1GB あたり 0.005 ドルで、最も低コストのストレージ

ウイルススキャン付きのユーザー向けアップロードの場合:

  • UploadThing — ウイルススキャン機能を内蔵
  • AWS S3 + Lambda — ClamAV を使った独自のスキャン

推奨: プロトタイプには UploadThing、本番環境での大規模運用には S3/R2 を使用します。

アナリティクスの選択基準

プロダクトアナリティクス向け:

  • PostHog - セルフホスト可能なオプション、セッションリプレイ、機能フラグ、無料枠は1Mイベント
  • Mixpanel - ユーザー中心のアナリティクス、無料枠は月間20Mイベント
  • Amplitude - 高度なコホート分析、無料枠は月間10Mイベント

ウェブアナリティクス向け:

  • Plausible - プライバシー重視、GDPR準拠、10Kページビューで月額$9
  • Umami - セルフホスト可能、シンプル、オープンソース
  • Vercel Analytics - Web Vitalsに特化、Vercelプロジェクトでは無料

推奨: プロダクトアプリにはPostHog、マーケティングサイトにはPlausibleをおすすめします。

フェーズ3:メール連携

ステップ3.1:Resend SDKをインストールする
precision_exec:
  commands:
    - cmd: "npm install resend"
      expect:
        exit_code: 0
  
  verbosity: minimal
ステップ3.2:メールクライアントを作成する

エラー処理を備えた、型安全なメールクライアントを作成します:

precision_write:
  files:
    - path: "src/lib/email.ts"
      mode: fail_if_exists
      content: |
        import type { ReactElement } from 'react';
        import { Resend } from 'resend';
        
        if (!process.env.RESEND_API_KEY) {
          throw new Error('RESEND_API_KEY is required');
        }
        
        export const resend = new Resend(process.env.RESEND_API_KEY);
        
        export interface SendEmailOptions {
          to: string | string[];
          subject: string;
          html?: string;
          react?: ReactElement;
          from?: string;
        }
        
        export async function sendEmail(options: SendEmailOptions) {
          const { to, subject, html, react, from = '[email protected]' } = options;
          
          try {
            const { data, error } = await resend.emails.send({
              from,
              to,
              subject,
              html,
              react,
            });
            
            if (error) {
              console.error('[Email] Send failed:', error);
              throw new Error(`Email send failed: ${error.message}`);
            }
            
            console.log('[Email] Sent successfully:', data?.id);
            return { success: true, id: data?.id };
          } catch (error: unknown) {
            console.error('[Email] Unexpected error:', error);
            throw error;
          }
        }
  
  verbosity: minimal
ステップ3.3:React Emailテンプレートを作成する

トランザクションメールには、型安全なテンプレートを作成できるReact Emailを使用します:

precision_exec:
  commands:
    - cmd: "npm install react-email @react-email/components"
      expect:
        exit_code: 0
  
  verbosity: minimal
precision_write:
  files:
    - path: "emails/welcome.tsx"
      mode: fail_if_exists
      content: |
        import {
          Body,
          Button,
          Container,
          Head,
          Heading,
          Html,
          Preview,
          Text,
        } from '@react-email/components';
        
        interface WelcomeEmailProps {
          userName: string;
          loginUrl: string;
        }
        
        export default function WelcomeEmail({ userName, loginUrl }: WelcomeEmailProps) {
          return (
            <Html>
              <Head />
              <Preview>Welcome to our platform!</Preview>
              <Body style={main}>
                <Container style={container}>
                  <Heading style={h1}>Welcome, {userName}!</Heading>
                  <Text style={text}>
                    We're excited to have you on board. Click the button below to get started.
                  </Text>
                  <Button href={loginUrl} style={button}>
                    Get Started
                  </Button>
                </Container>
              </Body>
            </Html>
          );
        }
        
        const main = { backgroundColor: '#f6f9fc', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif' };
        const container = { margin: '0 auto', padding: '40px 20px' };
        const h1 = { color: '#1f2937', fontSize: '24px', fontWeight: 'bold', marginBottom: '20px' };
        const text = { color: '#4b5563', fontSize: '16px', lineHeight: '24px', marginBottom: '20px' };
        const button = { backgroundColor: '#3b82f6', borderRadius: '6px', color: '#ffffff', display: 'inline-block', fontSize: '16px', fontWeight: '600', padding: '12px 24px', textDecoration: 'none' };
  
  verbosity: minimal
ステップ3.4:環境変数を文書化する
precision_write:
  files:
    - path: ".env.example"
      mode: overwrite
      content: |
        # Email (Resend)
        RESEND_API_KEY=your_resend_api_key_here
  
  verbosity: minimal

フェーズ4:CMS連携

ステップ4.1:Sanity SDKをインストールする
precision_exec:
  commands:
    - cmd: "npm install @sanity/client @sanity/image-url"
      expect:
        exit_code: 0
  
  verbosity: minimal
ステップ4.2:Sanityクライアントを作成する
precision_write:
  files:
    - path: "src/lib/sanity.ts"
      mode: fail_if_exists
      content: |
        import { createClient } from '@sanity/client';
        import imageUrlBuilder from '@sanity/image-url';
        import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
        
        if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) {
          throw new Error('NEXT_PUBLIC_SANITY_PROJECT_ID is required');
        }
        
        if (!process.env.NEXT_PUBLIC_SANITY_DATASET) {
          throw new Error('NEXT_PUBLIC_SANITY_DATASET is required');
        }
        
        export const sanityClient = createClient({
          projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
          dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
          apiVersion: '2026-01-01', // Update to current API version date
          useCdn: process.env.NODE_ENV === 'production',
        });
        
        const builder = imageUrlBuilder(sanityClient);
        
        // Import from @sanity/image-url
        export function urlForImage(source: SanityImageSource) {
          return builder.image(source).auto('format').fit('max');
        }
        
        // Type-safe query helper
        export async function sanityFetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {
          try {
            const result = await sanityClient.fetch<T>(query, params);
            return result;
          } catch (error: unknown) {
            console.error('[Sanity] Query failed:', error);
            throw new Error('Failed to fetch from Sanity');
          }
        }
  
  verbosity: minimal
ステップ4.3:Webhookエンドポイントを設定する

コンテンツをリアルタイムで更新するには、Webhookハンドラーを実装します:

precision_write:
  files:
    - path: "src/app/api/webhooks/sanity/route.ts"
      mode: fail_if_exists
      content: |
        import { NextRequest, NextResponse } from 'next/server';
        import { revalidateTag } from 'next/cache';
        
        import { timingSafeEqual } from 'crypto';

        export async function POST(request: NextRequest) {
          const signature = request.headers.get('sanity-webhook-signature');
          
          // Verify webhook signature
          const secret = process.env.SANITY_WEBHOOK_SECRET;
          if (!secret) {
            return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
          }
          const expected = Buffer.from(secret);
          const received = Buffer.from(signature || '');
          if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
            return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
          }
          
          try {
            const body = await request.json();
            const { _type } = body;
            
            // Revalidate cache based on content type
            if (_type === 'post') {
              revalidateTag('posts');
            } else if (_type === 'page') {
              revalidateTag('pages');
            }
            
            console.log('[Webhook] Sanity content updated:', _type); // Note: Use structured logger in production
            return NextResponse.json({ revalidated: true });
          } catch (error: unknown) {
            console.error('[Webhook] Failed to process Sanity webhook:', error);
            return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
          }
        }
  
  verbosity: minimal

フェーズ5:ファイルアップロード連携

ステップ5.1:UploadThing SDKをインストールする
precision_exec:
  commands:
    - cmd: "npm install uploadthing @uploadthing/react"
      expect:
        exit_code: 0
  
  verbosity: minimal
ステップ5.2:アップロードルーターを作成する
precision_write:
  files:
    - path: "src/app/api/uploadthing/core.ts"
      mode: fail_if_exists
      content: |
        import { createUploadthing, type FileRouter } from 'uploadthing/next';
        
        const f = createUploadthing();
        
        export const ourFileRouter = {
          imageUploader: f({ image: { maxFileSize: '4MB', maxFileCount: 4 } })
            .middleware(async ({ req }) => {
              // Authenticate user (placeholder imports shown for context)
              // In real code: import { getUserFromRequest } from '@/lib/auth';
              const user = await getUserFromRequest(req); // Import from your auth module
              if (!user) throw new Error('Unauthorized');
              
              return { userId: user.id };
            })
            .onUploadComplete(async ({ metadata, file }) => {
              console.log('[Upload] Complete:', file.url);
              
              // Save to database (Assumes Prisma client or similar ORM)
              await db.image.create({
                data: {
                  url: file.url,
                  userId: metadata.userId,
                },
              });
              
              return { url: file.url };
            }),
          
          pdfUploader: f({ pdf: { maxFileSize: '16MB' } })
            .middleware(async ({ req }) => {
              const user = await getUserFromRequest(req);
              if (!user) throw new Error('Unauthorized');
              return { userId: user.id };
            })
            .onUploadComplete(async ({ metadata, file }) => {
              console.log('[Upload] PDF complete:', file.url);
              return { url: file.url };
            }),
        } satisfies FileRouter;
        
        export type OurFileRouter = typeof ourFileRouter;
  
  verbosity: minimal
ステップ5.3:代替案 - S3の署名付きURL

より細かな制御が必要な場合は、署名付きURLを使ってS3を使用します:

precision_exec:
  commands:
    - cmd: "npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner"
      expect:
        exit_code: 0
  
  verbosity: minimal
precision_write:
  files:
    - path: "src/lib/s3.ts"
      mode: fail_if_exists
      content: |
        import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
        import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
        
        // Validate S3 configuration
        if (!process.env.AWS_REGION || !process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_S3_BUCKET) {
          throw new Error('Missing required AWS S3 environment variables');
        }
        
        const s3Client = new S3Client({
          region: process.env.AWS_REGION,
          credentials: {
            accessKeyId: process.env.AWS_ACCESS_KEY_ID,
            secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
          },
        });
        
        export async function getUploadUrl(key: string, contentType: string) {
          const command = new PutObjectCommand({
            Bucket: process.env.AWS_S3_BUCKET,
            Key: key,
            ContentType: contentType,
          });
          
          // URL expires in 5 minutes
          const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });
          return url;
        }
  
  verbosity: minimal

フェーズ6:アナリティクス連携

ステップ6.1:PostHog SDKをインストールする
precision_exec:
  commands:
    - cmd: "npm install posthog-js"
      expect:
        exit_code: 0
  
  verbosity: minimal
ステップ6.2:アナリティクスプロバイダーを作成する
precision_write:
  files:
    - path: "src/providers/analytics.tsx"
      mode: fail_if_exists
      content: |
        'use client';
        
        import { useEffect } from 'react';
        import type { ReactNode } from 'react';
        import posthog from 'posthog-js';
        
        export function AnalyticsProvider({ children }: { children: ReactNode }) {
          useEffect(() => {
            if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
              posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
                api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
                loaded: (posthog) => {
                  if (process.env.NODE_ENV === 'development') {
                    posthog.opt_out_capturing(); // Disabled in dev to avoid polluting analytics (re-enable for testing with posthog.opt_in_capturing())
                  }
                },
              });
            }
          }, []);
          
          return <>{children}</>;
        }
        
        // Helper for tracking events
        export function trackEvent(eventName: string, properties?: Record<string, unknown>) {
          if (typeof window !== 'undefined') {
            posthog.capture(eventName, properties);
          }
        }
  
  verbosity: minimal

フェーズ7:エラー処理のパターン

ステップ7.1:指数バックオフを実装する

レート制限のあるサービスには、再試行ロジックを実装します:

precision_write:
  files:
    - path: "src/lib/retry.ts"
      mode: fail_if_exists
      content: |
        export interface RetryOptions {
          maxAttempts?: number;
          initialDelayMs?: number;
          maxDelayMs?: number;
          backoffMultiplier?: number;
        }
        
        export async function withRetry<T>(
          fn: () => Promise<T>,
          options: RetryOptions = {}
        ): Promise<T> {
          const {
            maxAttempts = 3,
            initialDelayMs = 1000,
            maxDelayMs = 10000,
            backoffMultiplier = 2,
          } = options;
          
          let lastError: Error = new Error('All retry attempts failed');
          
          for (let attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
              return await fn();
            } catch (error: unknown) {
              lastError = error instanceof Error ? error : new Error(String(error));
              
              if (attempt === maxAttempts) {
                break;
              }
              
              const delayMs = Math.min(
                initialDelayMs * Math.pow(backoffMultiplier, attempt - 1),
                maxDelayMs
              );
              
              console.log(`[Retry] Attempt ${attempt}/${maxAttempts} failed, retrying in ${delayMs}ms`); // Note: Use structured logger in production
              await new Promise(resolve => setTimeout(resolve, delayMs));
            }
          }
          
          throw lastError;
        }
  
  verbosity: minimal
ステップ7.2:サーキットブレーカーを実装する

サーキットブレーカーを使って、障害の連鎖を防ぎます:

precision_write:
  files:
    - path: "src/lib/circuit-breaker.ts"
      mode: fail_if_exists
      content: |
        export class CircuitBreaker {
          private failureCount = 0;
          private lastFailureTime: number | null = null;
          private state: 'closed' | 'open' | 'half-open' = 'closed';
          
          constructor(
            private readonly failureThreshold = 5,
            private readonly resetTimeoutMs = 60000
          ) {}
          
          async execute<T>(fn: () => Promise<T>): Promise<T> {
            if (this.state === 'open') {
              if (Date.now() - (this.lastFailureTime ?? 0) > this.resetTimeoutMs) {
                this.state = 'half-open';
              } else {
                throw new Error('Circuit breaker is open');
              }
            }
            
            try {
              const result = await fn();
              this.onSuccess();
              return result;
            } catch (error: unknown) {
              this.onFailure();
              throw error;
            }
          }
          
          private onSuccess() {
            this.failureCount = 0;
            this.state = 'closed';
          }
          
          private onFailure() {
            this.failureCount++;
            this.lastFailureTime = Date.now();
            
            if (this.failureCount >= this.failureThreshold) {
              this.state = 'open';
              console.error('[CircuitBreaker] Circuit opened due to repeated failures');
            }
          }
        }
  
  verbosity: minimal

フェーズ8:サービス連携をテストする

ステップ8.1:サービスレスポンスをモックする

テスト用のモック実装を作成します:

precision_write:
  files:
    - path: "src/lib/__mocks__/email.ts"
      mode: fail_if_exists
      content: |
        import { SendEmailOptions } from '../email';
        
        const sentEmails: Array<SendEmailOptions & { id: string }> = [];
        
        export async function sendEmail(options: SendEmailOptions) {
          const id = `mock-${Date.now()}`;
          sentEmails.push({ ...options, id });
          return { success: true, id };
        }
        
        export function getSentEmails() {
          return sentEmails;
        }
        
        export function clearSentEmails() {
          sentEmails.length = 0;
        }
  
  verbosity: minimal
ステップ8.2:Webhookテストを実装する

Webhookハンドラーをローカルでテストします:

precision_write:
  files:
    - path: "scripts/test-webhook.sh"
      mode: fail_if_exists
      content: |
        #!/usr/bin/env bash
        set -euo pipefail
        
        # Requires: bash 4+
        # Test Sanity webhook locally
        
        echo "[INFO] Testing Sanity webhook..."
        if curl -X POST http://localhost:3000/api/webhooks/sanity \
          -H "Content-Type: application/json" \
          -H "sanity-webhook-signature: $SANITY_WEBHOOK_SECRET" \
          -d '{
            "_type": "post",
            "_id": "test-123",
            "title": "Test Post"
          }'; then
          echo "[PASS] Webhook test successful"
        else
          echo "[FAIL] Webhook test failed"
          exit 1
        fi
  
  verbosity: minimal

フェーズ9:検証

検証スクリプトを実行して、サービスが正しく連携されていることを確認します:

precision_exec:
  commands:
    - cmd: "bash plugins/goodvibes/skills/outcome/service-integration/scripts/validate-services.sh ."
      expect:
        exit_code: 0
  
  verbosity: standard

よくあるパターン

環境変数の検証

起動時に、必須の環境変数を必ず検証します:

const requiredEnvVars = ['RESEND_API_KEY', 'SANITY_PROJECT_ID'];

for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    throw new Error(`Missing required environment variable: ${envVar}`);
  }
}
外部リクエストのレート制限

トークンバケット方式のレート制限を実装します:

export class RateLimiter {
  private tokens: number;
  private lastRefill: number;
  
  constructor(
    private readonly maxTokens: number,
    private readonly refillRatePerSecond: number
  ) {
    this.tokens = maxTokens;
    this.lastRefill = Date.now();
  }
  
  async acquire(): Promise<void> {
    this.refill();
    
    if (this.tokens < 1) {
      const waitMs = (1 - this.tokens) * (1000 / this.refillRatePerSecond);
      await new Promise(resolve => setTimeout(resolve, waitMs));
      this.refill();
    }
    
    this.tokens -= 1;
  }
  
  private refill() {
    const now = Date.now();
    const elapsedSeconds = (now - this.lastRefill) / 1000;
    const tokensToAdd = elapsedSeconds * this.refillRatePerSecond;
    
    this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
    this.lastRefill = now;
  }
}

避けるべきアンチパターン

1. APIキーのハードコード

悪い例:

const resend = new Resend('re_abc123');

良い例:

if (!process.env.RESEND_API_KEY) {
  throw new Error('RESEND_API_KEY is required');
}
const resend = new Resend(process.env.RESEND_API_KEY);
2. エラー処理がない

悪い例:

const result = await resend.emails.send(options);
return result.data;

良い例:

const { data, error } = await resend.emails.send(options);
if (error) {
  console.error('[Email] Send failed:', error);
  throw new Error(`Email send failed: ${error.message}`);
}
return data;
3. 同期的な外部呼び出し

悪い例:

// Blocking user request
await sendEmail({ to: user.email, subject: 'Welcome' });
return res.json({ success: true });

許容される例(単純なケースの場合):

// Send email async
// Note: Fire-and-forget is only suitable for non-critical operations.
// For production: use a queue-based approach with retries (see BEST example below).
sendEmail({ to: user.email, subject: 'Welcome' })
  .catch(err => console.error('[Email] Failed:', err));
return res.json({ success: true });

最善の例(本番環境対応):

// Queue-based approach with retries
await emailQueue.add('welcome-email', {
  to: user.email,
  subject: 'Welcome'
});
return res.json({ success: true });
4. Webhookの検証がない

悪い例:

export async function POST(request: NextRequest) {
  const body = await request.json();
  // Process without verification
}

良い例:

import { timingSafeEqual } from 'crypto';

export async function POST(request: NextRequest) {
  const signature = request.headers.get('webhook-signature');
  
  // Validate webhook secret
  const secret = process.env.WEBHOOK_SECRET;
  if (!secret) {
    return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
  }
  
  const expected = Buffer.from(secret);
  const received = Buffer.from(signature || '');
  if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }
  const body = await request.json();
  // Process
}

トラブルシューティング

メールが送信されない
  1. APIキーが設定されていることを確認します:printf "%s\n" "${RESEND_API_KEY:0:5}..."(bashのパラメーター展開で先頭5文字を表示します)
  2. プロバイダーのダッシュボードでAPIキーの権限を確認します
  3. 送信者ドメインが検証済みであることを確認します
  4. プロバイダーのログでレート制限を確認します
  5. デバッグログを有効にします:resend.setDebug(true)
CMSのコンテンツが更新されない
  1. Webhookエンドポイントにパブリックアクセスできることを確認します
  2. Webhookシークレットが一致していることを確認します
  3. ngrokを使ってローカルでWebhookをテストします
  4. キャッシュの再検証が機能していることを確認します
  5. CMSのWebhookログを確認します
ファイルのアップロードに失敗する
  1. ファイルサイズが上限以内であることを確認します
  2. CORSの設定を確認します
  3. 認証ミドルウェアを確認します
  4. より小さいファイルでテストします
  5. S3を使用している場合は、S3バケットの権限を確認します
アナリティクスイベントが記録されない
  1. APIキーが設定されていることを確認します
  2. 広告ブロッカーがリクエストをブロックしていないか確認します
  3. 開発環境でオプトアウトが無効になっていることを確認します
  4. ブラウザーのコンソールでエラーを確認します
  5. PostHogのデバッグモードでテストします

関連スキル

  • api-design - 型安全なAPIレイヤーのパターン
  • authentication - サービスへの安全なアクセスパターン
  • testing-strategy - サービス連携のテスト

成功基準

  • サービス SDK がインストールされ、クライアントが作成されている
  • 環境変数が .env.example に記載されている
  • リトライを含むエラー処理が実装されている
  • Webhook エンドポイントで署名が検証されている
  • 送信リクエストにレート制限が設定されている
  • テスト用のモック実装が用意されている
  • ソースコードに API キーがハードコードされていない
  • すべての検証チェックに合格している

追加リソース

コメント