API統合ガイド
このガイドでは、MBC CQRS Serverlessアプリケーションを外部APIやサービスと統合する方法を説明します。HTTPリクエストの作成、Webhookの処理、リトライロジックの実装、および回復力のある統合を確保するためのパターンを学びます。
このガイドを使用するタイミング
以下のことが必要な場合にこのガイドを使用してください:
- Lambdaハンドラーやサービスからexternal REST APIを呼び出す
- サードパーティサービスからWebhookを受信する
- 外部API呼び出しのリトライとエラーハンドリングを実装する
- 外部システムとの回復力のある統合を構築する
このパターンが解決する問題
| 問題 | 解決策 |
|---|---|
| 外部API呼び出しが断続的に失敗する | 指数バックオフでリトライを実装 |
| サードパーティサービスがアプリにイベントを送信する | 署名検証付きのWebhookエンドポイントを作成 |
| ネットワークタイムアウトがLambda障害を引き起こす | 適切なタイムアウ トを設定し、タイムアウトエラーを処理 |
| 外部APIの問題を把握できない | 構造化ログとモニタリングを追加 |
外部APIの呼び出し
HTTPリクエストにfetchを使用する
フレームワークは内部サービスでHTTPリクエストにnode-fetchを使用しています。同じアプローチまたはNode.js互換の任意のHTTPクライアントライブラリを使用できます。
基本的なGETリクエスト
// external-api.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ExternalApiService {
private readonly logger = new Logger(ExternalApiService.name);
private readonly apiBaseUrl: string;
private readonly apiKey: string;
constructor(private readonly configService: ConfigService) {
this.apiBaseUrl = this.configService.get<string>('EXTERNAL_API_URL');
this.apiKey = this.configService.get<string>('EXTERNAL_API_KEY');
}
async getResource(resourceId: string): Promise<any> {
const url = `${this.apiBaseUrl}/resources/${resourceId}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
} catch (error) {
this.logger.error(`Failed to fetch resource ${resourceId}:`, error);
throw error;
}
}
}
ペイロード付きPOSTリクエスト
async createResource(data: CreateResourceDto): Promise<any> {
const url = `${this.apiBaseUrl}/resources`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`API request failed: ${response.status} - ${errorBody}`);
}
return await response.json();
} catch (error) {
this.logger.error('Failed to create resource:', error);
throw error;
}
}
Axiosの使用
HTTPリクエストにはAxiosまたはNestJS HttpModuleも使用できます:
npm install axios
// external-api.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance, AxiosError } from 'axios';
@Injectable()
export class ExternalApiService {
private readonly logger = new Logger(ExternalApiService.name);
private readonly client: AxiosInstance;
constructor(private readonly configService: ConfigService) {
this.client = axios.create({
baseURL: this.configService.get<string>('EXTERNAL_API_URL'),
timeout: 10000, // 10 second timeout (10秒タイムアウト)
headers: {
'Authorization': `Bearer ${this.configService.get<string>('EXTERNAL_API_KEY')}`,
'Content-Type': 'application/json',
},
});
// Response interceptor for logging (ログ用レスポンスインターセプター)
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
this.logger.error('API request failed:', {
url: error.config?.url,
status: error.response?.status,
message: error.message,
});
return Promise.reject(error);
},
);
}
async getResource(resourceId: string): Promise<any> {
const response = await this.client.get(`/resources/${resourceId}`);
return response.data;
}
async createResource(data: CreateResourceDto): Promise<any> {
const response = await this.client.post('/resources', data);
return response.data;
}
}
タイムアウトの設定
LambdaからAPIを呼び出す際は、常に適切なタイムアウトを設定してください:
// Recommended: Set timeout less than Lambda timeout (推奨:Lambdaタイムアウトより短いタイムアウトを設定)
const response = await fetch(url, {
method: 'GET',
headers: { /* ... */ },
signal: AbortSignal.timeout(25000), // 25 seconds - Lambda default is 30s (25秒 - Lambdaのデフォルトは30秒)
});
Axiosの場合:
const client = axios.create({
baseURL: apiUrl,
timeout: 25000, // 25 seconds (25秒)
});
Webhookの受信
Webhookコントローラーパターン
外部サービスからWebhookを受信するコントローラーを作成します:
// webhook.controller.ts
import {
Controller,
Post,
Body,
Headers,
HttpCode,
HttpStatus,
BadRequestException,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { WebhookService } from './webhook.service';
@ApiTags('webhooks')
@Controller('api/webhooks')
export class WebhookController {
private readonly logger = new Logger(WebhookController.name);
constructor(private readonly webhookService: WebhookService) {}
@Post('payment-provider')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Receive payment provider webhooks' })
async handlePaymentWebhook(
@Body() payload: any,
@Headers('x-signature') signature: string,
@Headers('x-timestamp') timestamp: string,
) {
this.logger.log(`Received payment webhook: ${payload.event}`);
// Verify webhook signature (Webhook署名を検証)
const isValid = await this.webhookService.verifySignature(
payload,
signature,
timestamp,
);
if (!isValid) {
throw new BadRequestException('Invalid webhook signature');
}
// Process the webhook event (Webhookイベントを処理)
await this.webhookService.processPaymentEvent(payload);
return { received: true };
}
}
Webhook署名の検証
リクエストが信頼できるソースからであることを確認するため、常にWebhook署名を検証してください:
// webhook.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
@Injectable()
export class WebhookService {
private readonly logger = new Logger(WebhookService.name);
private readonly webhookSecret: string;
constructor(private readonly configService: ConfigService) {
this.webhookSecret = this.configService.get<string>('WEBHOOK_SECRET');
}
/**
* Verify webhook signature using HMAC (HMACを使用してWebhook署名を検証)
*/
async verifySignature(
payload: any,
signature: string,
timestamp: string,
): Promise<boolean> {
if (!signature || !timestamp) {
return false;
}
// Check timestamp to prevent replay attacks (リプレイ攻撃を防ぐためタイムスタンプを確認)
const now = Math.floor(Date.now() / 1000);
const webhookTime = parseInt(timestamp, 10);
const tolerance = 300; // 5 minutes (5分)
if (Math.abs(now - webhookTime) > tolerance) {
this.logger.warn('Webhook timestamp is too old');
return false;
}
// Calculate expected signature (期待される署名を計算)
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const expectedSignature = crypto
.createHmac('sha256', this.webhookSecret)
.update(signedPayload)
.digest('hex');
// Use timing-safe comparison (タイミングセーフな比較を使用)
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} catch {
return false;
}
}
/**
* Process payment event based on type (タイプに基づいて決済イベントを処理)
*/
async processPaymentEvent(payload: any): Promise<void> {
const { event, data } = payload;
switch (event) {
case 'payment.completed':
await this.handlePaymentCompleted(data);
break;
case 'payment.failed':
await this.handlePaymentFailed(data);
break;
case 'refund.created':
await this.handleRefundCreated(data);
break;
default:
this.logger.warn(`Unhandled webhook event: ${event}`);
}
}
private async handlePaymentCompleted(data: any): Promise<void> {
// Update order status, trigger notifications, etc. (注文ステータスの更新、通知のトリガーなど)
this.logger.log(`Payment completed: ${data.paymentId}`);
}
private async handlePaymentFailed(data: any): Promise<void> {
this.logger.log(`Payment failed: ${data.paymentId}`);
}
private async handleRefundCreated(data: any): Promise<void> {
this.logger.log(`Refund created: ${data.refundId}`);
}
}
冪等なWebhook処理
Webhookは複数回配信される可能性があります。重複処理を防ぐために冪等性キーを使用してください:
// webhook.service.ts
import { PrismaService } from 'src/prisma';
@Injectable()
export class WebhookService {
constructor(
private readonly prismaService: PrismaService,
// ...
) {}
async processPaymentEvent(payload: any): Promise<void> {
const webhookId = payload.id;
// Check if already processed (既に処理済みか確認)
const existing = await this.prismaService.processedWebhook.findUnique({
where: { webhookId },
});
if (existing) {
this.logger.log(`Webhook ${webhookId} already processed, skipping`);
return;
}
// Process the webhook (Webhookを処理)
await this.handleEvent(payload);
// Mark as processed (処理済みとしてマーク)
await this.prismaService.processedWebhook.create({
data: {
webhookId,
eventType: payload.event,
processedAt: new Date(),
},
});
}
}