service-integration について
service-integrationは、サービス選定から検証まで、アプリケーションへの外部サービス導入を支援するスキルです。一般的なサービスの実装例に加え、エラー処理、再試行、本番APIを使わないテストを扱います。
主な機能
- 01サービス選定のガイド
- 02メール、CMS、アップロード、分析の導入パターン
- 03Webhookの設定と検証
- 04再試行とサーキットブレーカーのパターン
- 05モックを使った統合テスト
- 06検証スクリプト
活用シーン
- 01メールサービスやCMSの導入
- 02ファイルアップロードや分析の統合
- 03外部呼び出しの再試行と障害への対応
- 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
}
トラブルシューティング
メールが送信されない
- APIキーが設定されていることを確認します:
printf "%s\n" "${RESEND_API_KEY:0:5}..."(bashのパラメーター展開で先頭5文字を表示します) - プロバイダーのダッシュボードでAPIキーの権限を確認します
- 送信者ドメインが検証済みであることを確認します
- プロバイダーのログでレート制限を確認します
- デバッグログを有効にします:
resend.setDebug(true)
CMSのコンテンツが更新されない
- Webhookエンドポイントにパブリックアクセスできることを確認します
- Webhookシークレットが一致していることを確認します
- ngrokを使ってローカルでWebhookをテストします
- キャッシュの再検証が機能していることを確認します
- CMSのWebhookログを確認します
ファイルのアップロードに失敗する
- ファイルサイズが上限以内であることを確認します
- CORSの設定を確認します
- 認証ミドルウェアを確認します
- より小さいファイルでテストします
- S3を使用している場合は、S3バケットの権限を確認します
アナリティクスイベントが記録されない
- APIキーが設定されていることを確認します
- 広告ブロッカーがリクエストをブロックしていないか確認します
- 開発環境でオプトアウトが無効になっていることを確認します
- ブラウザーのコンソールでエラーを確認します
- PostHogのデバッグモードでテストします
関連スキル
- api-design - 型安全なAPIレイヤーのパターン
- authentication - サービスへの安全なアクセスパターン
- testing-strategy - サービス連携のテスト
成功基準
- サービス SDK がインストールされ、クライアントが作成されている
- 環境変数が .env.example に記載されている
- リトライを含むエラー処理が実装されている
- Webhook エンドポイントで署名が検証されている
- 送信リクエストにレート制限が設定されている
- テスト用のモック実装が用意されている
- ソースコードに API キーがハードコードされていない
- すべての検証チェックに合格している