🔧 カスタムサービスの作成
このガイドでは、Nodeblocks SDKを使用して完全なカスタムサービスを作成する手順を説明します。すべての重要なパターンとコンポーネントを実演するレビューサービスを構築します。この簡単な例では、ユーザーが商品レビューを作成・取得できるようになります。
📦 必要なパッケージ: この例では
ramda
とneverthrow
パッケージを使用します。必ずインストールしてください:npm install ramda neverthrow
🏗️ サービスアーキテクチャ
サービスを構築するために、以下のコアコンポーネントを実装します:
- スキーマ - データ検証とTypeScript型
- ハンドラー - ビジネスロジック関数
- ルート - HTTPエンドポイント定義
- フィーチャー - 構成されたスキーマ + ルート
- サービス - すべてを結び付けるファクトリ関数
1️⃣ スキーマの定義
まず、src/schemas
ディレクトリ内にreview.ts
ファイルを作成します。
ここでは、必須フィールドproductId
(文字列)とrating
(1-5の数値)、およびオプションのcomment
フィールドを含むスキーマを定義します。
src/schemas/review.ts
import { primitives } from '@nodeblocks/backend-sdk';
const { withSchema } = primitives;
const reviewIdPathParameter: primitives.OpenAPIParameter = {
in: 'path',
name: 'reviewId',
required: true,
schema: {
type: 'string',
},
};
export const reviewSchema: primitives.SchemaDefinition = {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: {
productId: { type: 'string' },
rating: { type: 'number', minimum: 1, maximum: 5 },
comment: { type: 'string' },
},
type: 'object',
};
export const createReviewSchema = withSchema({
requestBody: {
content: {
'application/json': {
schema: { ...reviewSchema, required: ['productId', 'rating'] },
},
},
required: true,
},
});
export const updateReviewSchema = withSchema({
parameters: [{ ...reviewIdPathParameter }],
requestBody: {
content: {
'application/json': {
schema: {
additionalProperties: false,
properties: {
comment: { type: 'string' },
},
required: [],
type: 'object',
},
},
},
required: true,
},
});
export const getReviewSchema = withSchema({
parameters: [{ ...reviewIdPathParameter }],
});
2️⃣ ハンドラーの作成
次に、src/handlers
ディレクトリ内にreview.ts
ファイルを作成し、レビューの作成と取得を処理するビジネスロジックを実装します。
src/handlers/review.ts
import { ok, err } from 'neverthrow';
import { primitives, handlers, utils } from '@nodeblocks/backend-sdk';
const { NodeblocksError, RouteHandlerPayload } = primitives;
const { mergeData } = handlers;
const { createBaseEntity } = utils;
// レビュー作成ハンドラー
export const createReview = async (payload: RouteHandlerPayload) => {
const { params, context } = payload;
if (!params.requestBody || Object.keys(params.requestBody).length === 0) {
return err(
new NodeblocksError(400, 'リクエストボディが必要です', [], 'createReview')
);
}
const reviewEntity = createBaseEntity(params.requestBody);
try {
const createdReview = await context.db.reviews.insertOne(reviewEntity);
if (!createdReview.insertedId) {
return err(
new NodeblocksError(400, 'レビューの作成に失敗しました', [], 'createReview')
);
}
return ok(mergeData(payload, { reviewId: reviewEntity.id }));
} catch (error) {
return err(
new NodeblocksError(500, 'レビューの作成に失敗しました', [], 'createReview')
);
}
};
// レビュー取得ハンドラー
export const getReviewById = async (payload: RouteHandlerPayload) => {
const { params, context } = payload;
if (!params.requestParams?.reviewId) {
return err(
new NodeblocksError(400, 'レビューIDが必要です', [], 'getReviewById')
);
}
try {
const review = await context.db.reviews.findOne({
id: params.requestParams.reviewId,
});
if (!review) {
return err(
new NodeblocksError(404, 'レビューが見つかりません', [], 'getReviewById')
);
}
return ok(mergeData(payload, { review }));
} catch (error) {
return err(
new NodeblocksError(500, 'レビューの取得に失敗しました', [], 'getReviewById')
);
}
};
// 商品別レビュー検索ハンドラー
export const findReviewsByProduct = async (payload: RouteHandlerPayload) => {
const { params, context } = payload;
if (!params.requestParams?.productId) {
return err(
new NodeblocksError(400, '商品IDが必要です', [], 'findReviewsByProduct')
);
}
try {
const reviews = await context.db.reviews
.find({ productId: params.requestParams.productId })
.toArray();
return ok(mergeData(payload, { reviews }));
} catch (error) {
return err(
new NodeblocksError(500, 'レビューの検索に失敗しました', [], 'findReviewsByProduct')
);
}
};
// レビュー更新ハンドラー
export const updateReview = async (payload: RouteHandlerPayload) => {
const { params, context } = payload;
if (!params.requestParams?.reviewId) {
return err(
new NodeblocksError(400, 'レビューIDが必要です', [], 'updateReview')
);
}
if (!params.requestBody || Object.keys(params.requestBody).length === 0) {
return err(
new NodeblocksError(400, '更新データが必要です', [], 'updateReview')
);
}
try {
const updateData = {
...params.requestBody,
updatedAt: new Date().toISOString(),
};
const result = await context.db.reviews.updateOne(
{ id: params.requestParams.reviewId },
{ $set: updateData }
);
if (result.modifiedCount === 0) {
return err(
new NodeblocksError(404, 'レビューが見つからないか、更新されませんでした', [], 'updateReview')
);
}
return ok(mergeData(payload, { reviewId: params.requestParams.reviewId }));
} catch (error) {
return err(
new NodeblocksError(500, 'レビューの更新に失敗しました', [], 'updateReview')
);
}
};
// ターミネーターハンドラー
export const normalizeReviewTerminator = (result) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;
if (!context.data?.review) {
throw new NodeblocksError(
500,
'レビューの正規化で不明なエラー',
[],
'normalizeReviewTerminator'
);
}
const { _id, ...review } = context.data.review;
return review;
};
export const normalizeReviewsListTerminator = (result) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;
if (!context.data?.reviews) {
throw new NodeblocksError(
500,
'レビューリストの正規化で不明なエラー',
[],
'normalizeReviewsListTerminator'
);
}
return context.data.reviews.map(review => {
const { _id, ...normalizedReview } = review;
return normalizedReview;
});
};
export const createReviewTerminator = (result) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;
return {
reviewId: context.data.reviewId,
message: 'レビューが正常に作成されました'
};
};
3️⃣ ルートの定義
src/routes
ディレクトリ内にreview.ts
ファイルを作成し、HTTPエンドポイントを定義します。
src/routes/review.ts
import { primitives } from '@nodeblocks/backend-sdk';
import {
createReview,
getReviewById,
findReviewsByProduct,
updateReview,
normalizeReviewTerminator,
normalizeReviewsListTerminator,
createReviewTerminator,
} from '../handlers/review';
const { withRoute, compose, flatMapAsync, lift } = primitives;
// レビュー作成ルート
export const createReviewRoute = withRoute({
method: 'POST',
path: '/reviews',
handler: compose(
createReview,
lift(createReviewTerminator)
),
});
// レビュー取得ルート
export const getReviewRoute = withRoute({
method: 'GET',
path: '/reviews/:reviewId',
handler: compose(
getReviewById,
lift(normalizeReviewTerminator)
),
});
// 商品別レビュー検索ルート
export const findReviewsByProductRoute = withRoute({
method: 'GET',
path: '/products/:productId/reviews',
handler: compose(
findReviewsByProduct,
lift(normalizeReviewsListTerminator)
),
});
// レビュー更新ルート
export const updateReviewRoute = withRoute({
method: 'PATCH',
path: '/reviews/:reviewId',
handler: compose(
updateReview,
flatMapAsync(getReviewById),
lift(normalizeReviewTerminator)
),
});
4️⃣ フィーチャーの構成
src/features
ディレクトリ内にreview.ts
ファイルを作成し、スキーマとルートを組み合わせます。
src/features/review.ts
import { primitives } from '@nodeblocks/backend-sdk';
import {
createReviewSchema,
getReviewSchema,
updateReviewSchema,
} from '../schemas/review';
import {
createReviewRoute,
getReviewRoute,
findReviewsByProductRoute,
updateReviewRoute,
} from '../routes/review';
const { compose } = primitives;
// レビュー作成フィーチャー
export const createReviewFeature = compose(
createReviewSchema,
createReviewRoute
);
// レビュー取得フィーチャー
export const getReviewFeature = compose(
getReviewSchema,
getReviewRoute
);
// 商品別レビュー検索フィーチャー(スキーマなし)
export const findReviewsByProductFeature = findReviewsByProductRoute;
// レビュー更新フィーチャー
export const updateReviewFeature = compose(
updateReviewSchema,
updateReviewRoute
);
5️⃣ サービスの作成
最後に、src/services
ディレクトリ内にreview.ts
ファイルを作成し、すべてを1つのサービスに結合します。
src/services/review.ts
import { primitives } from '@nodeblocks/backend-sdk';
import { partial } from 'lodash';
import {
createReviewFeature,
getReviewFeature,
findReviewsByProductFeature,
updateReviewFeature,
} from '../features/review';
const { compose, defService } = primitives;
export const reviewService = (dataStores: any, configuration: any) =>
defService(
partial(
compose(
createReviewFeature,
getReviewFeature,
findReviewsByProductFeature,
updateReviewFeature
),
{ dataStores, configuration }
)
);
6️⃣ サーバーのセットアップ
すべてを1つのExpressサーバーに統合します。
src/server.ts
import express from 'express';
import { middlewares, drivers } from '@nodeblocks/backend-sdk';
import { reviewService } from './services/review';
const { nodeBlocksErrorMiddleware } = middlewares;
const { getMongoClient } = drivers;
const app = express();
// ミドルウェア設定
app.use(express.json());
// データベース接続
const client = getMongoClient(
process.env.MONGO_URL || 'mongodb://localhost:27017',
process.env.DB_NAME || 'review-service'
);
const dataStores = {
reviews: client.collection('reviews')
};
const configuration = {
// 必要に応じて設定を追加
};
// サービスを統合
app.use('/api', reviewService(dataStores, configuration));
// エラーハンドリングミドルウェア
app.use(nodeBlocksErrorMiddleware());
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 レビューサービスがポート ${PORT} で起動しました`);
console.log(`📚 利用可能なエンドポイント:`);
console.log(` POST /api/reviews - レビュー作成`);
console.log(` GET /api/reviews/:reviewId - レビュー取得`);
console.log(` GET /api/products/:productId/reviews - 商品別レビュー検索`);
console.log(` PATCH /api/reviews/:reviewId - レビュー更新`);
});
7️⃣ テスト
APIをテストします:
# レビュー作成
curl -X POST http://localhost:3000/api/reviews \
-H "Content-Type: application/json" \
-d '{
"productId": "prod-123",
"rating": 5,
"comment": "素晴らしい商品です!"
}'
# レビュー取得
curl http://localhost:3000/api/reviews/review-id-here
# 商品別レビュー検索
curl http://localhost:3000/api/products/prod-123/reviews
# レビュー更新
curl -X PATCH http://localhost:3000/api/reviews/review-id-here \
-H "Content-Type: application/json" \
-d '{
"comment": "更新されたレビューコメント"
}'
8️⃣ 高度な機能
バリデーターの追加
src/validators/review.ts
import { NodeblocksError } from '@nodeblocks/backend-sdk';
export const validateProductExists = async (payload) => {
const { params, context } = payload;
const productId = params.requestBody?.productId || params.requestParams?.productId;
if (!productId) {
throw new NodeblocksError(400, '商品IDが必要です');
}
// 商品の存在確認(実際のプロダクトサービスを呼び出し)
const product = await context.db.products?.findOne({ id: productId });
if (!product) {
throw new NodeblocksError(404, '指定された商品が見つかりません');
}
};
export const validateReviewOwnership = async (payload) => {
const { params, context } = payload;
const reviewId = params.requestParams?.reviewId;
const userId = context.user?.id; // 認証ミドルウェアから取得
if (!userId) {
throw new NodeblocksError(401, '認証が必要です');
}
const review = await context.db.reviews.findOne({ id: reviewId });
if (!review) {
throw new NodeblocksError(404, 'レビューが見つかりません');
}
if (review.userId !== userId) {
throw new NodeblocksError(403, 'このレビューを編集する権限がありません');
}
};
ミドルウェア付きルート
src/routes/review.ts
export const updateReviewRoute = withRoute({
method: 'PATCH',
path: '/reviews/:reviewId',
validators: [
verifyAuthentication(getBearerTokenInfo),
validateReviewOwnership
],
handler: compose(
updateReview,
flatMapAsync(getReviewById),
lift(normalizeReviewTerminator)
),
});
ページネーション機能
src/handlers/review.ts
export const findReviewsByProductPaginated = async (payload: RouteHandlerPayload) => {
const { params, context } = payload;
const { productId } = params.requestParams;
const page = parseInt(params.requestQuery?.page as string) || 1;
const limit = parseInt(params.requestQuery?.limit as string) || 10;
const skip = (page - 1) * limit;
try {
const reviews = await context.db.reviews
.find({ productId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.toArray();
const total = await context.db.reviews.countDocuments({ productId });
return ok(mergeData(payload, {
reviews,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1
}
}));
} catch (error) {
return err(
new NodeblocksError(500, 'レビューの検索に失敗しました', [], 'findReviewsByProductPaginated')
);
}
};
🎯 ベストプラクティス
1. エラーハンドリング
// 一貫性のあるエラーメッセージ
return err(
new NodeblocksError(
400, // HTTPステータスコード
'わかりやすいエラーメッセージ', // ユーザー向けメッセージ
['詳細な検証エラー'], // 追加詳細
'handlerFunctionName' // デバッグ用関数名
)
);
2. 型安全性
// TypeScript型の定義
interface Review {
id: string;
productId: string;
rating: number;
comment?: string;
createdAt: string;
updatedAt: string;
}
interface ReviewContext {
review?: Review;
reviews?: Review[];
reviewId?: string;
}
3. テスタビリティ
// ハンドラーのユニットテスト
describe('createReview', () => {
test('有効なデータでレビューを作成', async () => {
const mockPayload = {
params: {
requestBody: {
productId: 'prod-123',
rating: 5,
comment: 'Great product!'
}
},
context: {
db: {
reviews: {
insertOne: jest.fn().mockResolvedValue({ insertedId: 'review-123' })
}
}
}
};
const result = await createReview(mockPayload);
expect(result.isOk()).toBe(true);
});
});
➡️ 次のステップ
- カスタムデータストアでデータ層をカスタマイズ
- スキーマオーバーライドで検証を強化
- 複合サービスで他のサービスと統合