メインコンテンツまでスキップ
バージョン: 0.13.0 (Previous)

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

このガイドでは、Nodeblocks SDKを使用して完全なカスタムサービスを作成する方法を順を追って説明します。Review Service を構築し、すべての主要なパターンとコンポーネントを示します。このシンプルな例では、ユーザーが製品レビューを作成・取得できます。

📦 必要なパッケージ: この例は直接Express、SDK、ramdaneverthrow、およびMongoDB型をインポートします。インストールしてください:

npm install @nodeblocks/backend-sdk express ramda neverthrow mongodb
npm install --save-dev @types/express @types/node @types/ramda

⚙️ モジュール設定: bootstrap例はトップレベルの await を使用します。ESMモジュールとして実行するか(例えば tsconfig.json"module": "NodeNext"package.json"type": "module")、bootstrapコードを async function main() に移動してください。


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

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

  1. スキーマ - データバリデーションとTypeScript型
  2. ブロック - ビジネスロジック関数
  3. ルート - HTTPエンドポイント定義
  4. フィーチャー - 合成されたスキーマ + ルート
  5. サービス - すべてを接続するファクトリ関数

1️⃣ スキーマの定義

まず、src/schemas ディレクトリ内に review.ts ファイルを作成します。 ここで必須フィールド productId(文字列)、identityId(文字列)、および 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'},
identityId: {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', 'identityId', 'rating'],
},
},
},
required: true,
},
});

export const getReviewSchema = withSchema({
parameters: [{...reviewIdPathParameter}],
});

2️⃣ ブロックの作成

次に、src/blocks ディレクトリ内に review.ts ファイルを作成します。 レビューサービス用のビジネスロジックを含むブロックを追加します。これらの関数はレビューの作成、IDによる取得、およびデータ形式の正規化を処理します。

src/blocks/review.ts
import {Result, ok, err} from 'neverthrow';
import {Collection, WithId, Document} from 'mongodb';
import {utils, primitives} from '@nodeblocks/backend-sdk';

const {createBaseEntity} = utils;
const {BlockError, hasValue} = primitives;

export class ReviewBlockError extends BlockError {}
export class ReviewNotFoundBlockError extends ReviewBlockError {}

/**
* データベースに新しいレビューを作成します。
* 作成されたレビューのIDを文字列として返します。
*/
export async function createReview(
reviewsCollection: Collection,
reviewData: Record<string, unknown>,
): Promise<Result<string, ReviewBlockError>> {
try {
const entity = createBaseEntity(reviewData);
const result = await reviewsCollection.insertOne(entity);

if (!result.insertedId) {
return err(new ReviewBlockError('Failed to create review'));
}

return ok(entity.id);
} catch (error) {
return err(new ReviewBlockError('Failed to create review'));
}
}

/**
* IDでレビューをデータベースから取得します。
*/
export async function getReviewById(
reviewsCollection: Collection,
reviewId: string,
): Promise<Result<WithId<Document>, ReviewBlockError>> {
try {
const result = await reviewsCollection.findOne({id: String(reviewId)});
if (!hasValue(result)) {
return err(new ReviewNotFoundBlockError('Review not found'));
}
return ok(result);
} catch (error) {
return err(new ReviewBlockError('Failed to get review'));
}
}

/**
* 複数のレビューをオプションのフィルターで検索。
*/
export async function findReviews(
reviewsCollection: Collection,
filter: Record<string, unknown> = {},
): Promise<Result<WithId<Document>[], ReviewBlockError>> {
try {
const reviews = await reviewsCollection.find(filter).toArray();
return ok(reviews);
} catch (error) {
return err(new ReviewBlockError('Failed to find reviews'));
}
}

/**
* '_id' を削除して単一のレビューを正規化します。
*/
export function normalizeReview<T extends Record<string, unknown>>({_id, ...object}: T): Result<Omit<T, '_id'>, Error> {
return ok(object);
}

/**
* '_id' を削除してレビュー配列を正規化します。
*/
export function normalizeReviews<T extends Record<string, unknown>>(objects: T[]): Result<Omit<T, '_id'>[], Error> {
return ok(objects.map(({_id, ...object}) => object));
}

3️⃣ ルートの定義

src/routes ディレクトリ内に review.ts ファイルを作成します。 ルートはHTTPエンドポイントを定義し、ブロックに接続します。レビュー作成用と取得用の2つのルートを作成します。

src/routes/review.ts
import {primitives, validators} from '@nodeblocks/backend-sdk';
import {
createReview,
getReviewById,
findReviews,
normalizeReview,
normalizeReviews,
ReviewNotFoundBlockError,
ReviewBlockError,
} from '../blocks/review';

const {compose, flatMapAsync, lift, withLogging, withRoute, applyPayloadArgs, orThrow} = primitives;

const {isAuthenticated, isSelf} = validators;

export const createReviewRoute = withRoute({
method: 'POST',
path: '/reviews',
validators: [
isAuthenticated(),
// identityId は POST /reviews のリクエスト本文から来ます
isSelf(['params', 'requestBody', 'identityId']),
],
handler: compose(
withLogging(
applyPayloadArgs(
createReview,
[
['context', 'db', 'reviews'],
['params', 'requestBody'],
],
'createdReview',
),
),
// createReview は context.data.createdReview 下に文字列IDを保存
flatMapAsync(
withLogging(
applyPayloadArgs(
getReviewById,
[
['context', 'db', 'reviews'],
['context', 'data', 'createdReview'],
],
'review',
),
),
),
flatMapAsync(applyPayloadArgs(normalizeReview, [['context', 'data', 'review']], 'normalizedReview')),
lift(
withLogging(
orThrow(
[
[ReviewNotFoundBlockError, 404],
[ReviewBlockError, 500],
],
[['context', 'data', 'normalizedReview'], 200],
),
),
),
),
});

export const getReviewRoute = withRoute({
method: 'GET',
path: '/reviews/:reviewId',
validators: [],
handler: compose(
withLogging(
applyPayloadArgs(
getReviewById,
[
['context', 'db', 'reviews'],
['params', 'requestParams', 'reviewId'],
],
'review',
),
),
flatMapAsync(applyPayloadArgs(normalizeReview, [['context', 'data', 'review']], 'normalizedReview')),
lift(
withLogging(
orThrow(
[
[ReviewNotFoundBlockError, 404],
[ReviewBlockError, 500],
],
[['context', 'data', 'normalizedReview'], 200],
),
),
),
),
});

export const listReviewsRoute = withRoute({
method: 'GET',
path: '/reviews',
validators: [],
handler: compose(
withLogging(applyPayloadArgs(findReviews, [['context', 'db', 'reviews']], 'reviews')),
flatMapAsync(applyPayloadArgs(normalizeReviews, [['context', 'data', 'reviews']], 'normalizedReviews')),
lift(withLogging(orThrow([[ReviewBlockError, 500]], [['context', 'data', 'normalizedReviews'], 200]))),
),
});

4️⃣ フィーチャーの合成

src/features ディレクトリ内に review.ts ファイルを作成します。 フィーチャーはスキーマとルートを組み合わせて完全なAPIエンドポイントを作成します。このステップはスキーマバリデーションロジックをルートに接続します。

src/features/review.ts
import {primitives} from '@nodeblocks/backend-sdk';
import {createReviewRoute, getReviewRoute, listReviewsRoute} from '../routes/review';
import {createReviewSchema, getReviewSchema} from '../schemas/review';

const {compose} = primitives;

export const createReviewFeature = compose(createReviewSchema, createReviewRoute);
export const getReviewFeature = compose(getReviewSchema, getReviewRoute);
export const listReviewsFeature = listReviewsRoute;

5️⃣ サービスの作成

src/services ディレクトリ内に review.ts ファイルを作成します。 サービスはすべてを結合するファクトリ関数です。データベース接続と設定を取り、完全なExpressルーターを返します。

src/services/review.ts
import {partial} from 'ramda';
import {primitives} from '@nodeblocks/backend-sdk';
import {createReviewFeature, getReviewFeature, listReviewsFeature} from '../features/review';

const {compose, defService} = primitives;

export const reviewService: primitives.Service = (dataStores, configuration) =>
defService(
partial(compose(createReviewFeature, getReviewFeature, listReviewsFeature), [{dataStores, configuration}]),
);

6️⃣ サービスの使用

最後に、src ディレクトリ内の index.ts ファイルを作成または更新します。 ここでReview ServiceをExpressとMongoDBに接続し、実際の実行中アプリケーションを作成します。

src/index.ts
import express from 'express';
import {middlewares, drivers} from '@nodeblocks/backend-sdk';
import {reviewService} from './services/review';

const {withMongo} = drivers;
const {nodeBlocksErrorMiddleware} = middlewares;

const connectToDatabase = withMongo('mongodb://localhost:27017/?authSource=admin', 'dev', 'user', 'password');

express()
.use(
reviewService(await connectToDatabase('reviews'), {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
}),
)
.use(nodeBlocksErrorMiddleware())
.listen(8089, () => console.log('Server running'));

ノート: POST /reviewsisAuthenticated()isSelf(...) を使用するので、作成リクエストにはボディと一致する identityId を持つ有効なユーザーアクセストークンが必要です。この例では提供された authSecrets でトークンを検証しますが、発行はしません。保護されたエンドポイントをテストする前に、services.authService (必要な identities データストアおよび適用可能な場合 onetimetokens)をマウントするか、別の信頼できるJWT発行者を使用してください。


🧪 サービスのテスト

# レビューを作成(必須フィールドのみ)
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access-token>' \
-d '{"productId":"123","identityId":"6dcdd50a-e0e6-445d-82e1-3da35bc2d149","rating":5}'

# オプションのコメント付きでレビューを作成
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access-token>' \
-d '{"productId":"123","identityId":"6dcdd50a-e0e6-445d-82e1-3da35bc2d149","rating":5,"comment":"Great product!"}'

# レビューを取得
curl "http://localhost:8089/reviews/<reviewId>"

# レビューの一覧
curl http://localhost:8089/reviews

期待されるレスポンス:

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"identityId": "6dcdd50a-e0e6-445d-82e1-3da35bc2d149",
"productId": "123",
"rating": 5,
"comment": "Great product!",
"createdAt": "2024-01-01T12:00:00.000Z",
"updatedAt": "2024-01-01T12:00:00.000Z"
}

🔧 トラブルシューティング

HTMLなのにJSONエラーではない?

APIがJSONではなくHTMLエラーページを返す場合、エラーミドルウェアを見逃している可能性が高いです:

const connectToDatabase = withMongo('mongodb://localhost:27017/?authSource=admin', 'dev', 'user', 'password');
const reviewServiceConfig = {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
};

// ❌ エラーミドルウェアが不足
express()
.use(reviewService(await connectToDatabase('reviews'), reviewServiceConfig))
.listen(8089, () => console.log('Server running'));

// ✅ エラーミドルウェア付きで正しい
express()
.use(reviewService(await connectToDatabase('reviews'), reviewServiceConfig))
.use(nodeBlocksErrorMiddleware()) // この行を追加!
.listen(8089, () => console.log('Server running'));

エラーミドルウェアは最後に設定必须

エラーミドルウェアはすべてのサービスとルートの後に追加する必要があります:

const connectToDatabase = withMongo('mongodb://localhost:27017/?authSource=admin', 'dev', 'user', 'password');
const reviewServiceConfig = {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
};

// ✅ 正しい順序
express()
.use(reviewService(await connectToDatabase('reviews'), reviewServiceConfig))
.use(nodeBlocksErrorMiddleware()) // 最後に!
.listen(8089, () => console.log('Server running'));

➡️ 次のステップ

今、Review Serviceにもっと機能を追加することで実習できます。以下のレビューサービスに必要なエンドポイントの実装を試してください:

  • 更新と削除操作 - レビューの変更と削除のためにPATCHとDELETEエンドポイントをを追加
  • 合成セットアップ - このサービスを認証およびプロフィールフィーチャーと組み合わせる (合成サービスの作成)
  • カスタムストレージ - MongoDBをカスタムアダプターに交換する (カスタムDataStoreの使用)