メインコンテンツまでスキップ
バージョン: 0.4.2

🔧 カスタムサービスの作成

このガイドでは、Nodeblocks SDKを使用して完全なカスタムサービスを作成する手順を説明します。すべての重要なパターンとコンポーネントを実演するレビューサービスを構築します。この簡単な例では、ユーザーが商品レビューを作成・取得できるようになります。

📦 必要なパッケージ: この例ではramdaneverthrowパッケージを使用します。必ずインストールしてください:

npm install ramda neverthrow

🏗️ サービスアーキテクチャ

サービスを構築するために、以下のコアコンポーネントを実装します:

  1. スキーマ - データ検証とTypeScript型
  2. ハンドラー - ビジネスロジック関数
  3. ルート - HTTPエンドポイント定義
  4. フィーチャー - 構成されたスキーマ + ルート
  5. サービス - すべてを結び付けるファクトリ関数

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);
});
});

➡️ 次のステップ