๐ง Creating a Custom Service
This guide walks you through creating a complete custom service using the Nodeblocks SDK. We'll build a Review Service that demonstrates all the key patterns and components. This simple example will allow users to create and retrieve product reviews.
๐ฆ Required Packages: This example uses
ramdaandneverthrowpackages. Make sure to install them:npm install ramda neverthrow
๐๏ธ Service Architectureโ
In order to build the service, we will implement the following core components:
- Schemas - Data validation and TypeScript types
- Handlers - Business logic functions
- Routes - HTTP endpoint definitions
- Features - Composed schemas + routes
- Service - Factory function that wires everything together
1๏ธโฃ Define Schemaโ
First, create a review.ts file inside the src/schemas directory.
Here we define a schema that contains the required fields productId (string) and rating (number from 1-5), along with an optional comment field.
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 updateReviewSchema = withSchema({
parameters: [{ ...reviewIdPathParameter }],
requestBody: {
content: {
'application/json': {
schema: {
additionalProperties: false,
properties: {
comment: { type: 'string' },
},
required: [],
type: 'object',
},
},
},
required: true,
},
});
2๏ธโฃ Create Blocksโ
Next, create a review.ts file inside the src/blocks directory.
We'll add blocks that contain the business logic for our review service. These functions handle creating reviews, retrieving them by ID, and normalizing the data format.
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 {}
/**
* Creates a new review in the database.
*/
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'));
}
}
/**
* Retrieves a review by ID from the database.
*/
export async function getReviewById(
reviewsCollection: Collection,
reviewId: string
): Promise<Result<WithId<Document> | null, 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'));
}
}
/**
* Finds multiple reviews with optional filtering.
*/
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'));
}
}
/**
* Normalizes a single review by removing MongoDB-specific fields.
*/
export function normalizeReview<T extends Record<string, unknown>>({
_id,
...object
}: T): Result<Omit<T, '_id'>, Error> {
return ok(object);
}
/**
* Normalizes an array of reviews by removing MongoDB-specific fields.
*/
export function normalizeReviews<T extends Record<string, unknown>>(
objects: T[]
): Result<Omit<T, '_id'>[], Error> {
return ok(objects.map(({ _id, ...object }) => object));
}
3๏ธโฃ Define Routesโ
Create a review.ts file inside the src/routes directory.
Routes define HTTP endpoints and connect them to our blocks. We'll create three routes: one for creating reviews and two to retrieve them.
import { primitives, validators } from '@nodeblocks/backend-sdk';
const {
compose,
flatMapAsync,
lift,
withLogging,
withRoute,
applyPayloadArgs,
orThrow,
} = primitives;
import {
createReview,
getReviewById,
findReviews,
normalizeReview,
normalizeReviews,
ReviewNotFoundBlockError,
ReviewBlockError,
} from '../blocks/review';
const { isAuthenticated, isSelf } = validators;
export const createReviewRoute = withRoute({
method: 'POST',
path: '/reviews',
validators: [
isAuthenticated(),
isSelf(['params', 'requestParams', 'identityId']),
],
handler: compose(
withLogging(
applyPayloadArgs(
createReview,
[
['context', 'db', 'reviews'],
['params', 'requestBody'],
],
'createdReview'
)
),
flatMapAsync(
withLogging(
applyPayloadArgs(
getReviewById,
[
['context', 'db', 'reviews'],
['context', 'data', 'createdReview', 'id'],
],
'review'
)
)
),
flatMapAsync(
applyPayloadArgs(
normalizeReview,
[['context', 'data', 'review']],
'normalizedReview'
)
),
lift(
withLogging(
orThrow(
[[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(
[
[ReviewBlockError, 500],
[ReviewNotFoundBlockError, 404],
],
[['context', 'data', 'normalizedReview'], 200]
)
)
)
),
});
export const listReviewsRoute = withRoute({
method: 'GET',
path: '/reviews',
validators: [],
handler: compose(
withLogging(
applyPayloadArgs(
findReviews,
[
['context', 'db', 'reviews'],
[], // empty filter for now
],
'reviews'
)
),
flatMapAsync(
applyPayloadArgs(
normalizeReviews,
[['context', 'data', 'reviews']],
'normalizedReviews'
)
),
lift(
withLogging(
orThrow(
[[ReviewBlockError, 500]],
[['context', 'data', 'normalizedReviews'], 200]
)
)
)
),
});
4๏ธโฃ Compose Featuresโ
Create a review.ts file inside the src/features directory.
Features combine schemas with routes to create complete API endpoints. This step links schema validation logic with routes
import { primitives } from '@nodeblocks/backend-sdk';
import {
createReviewRoute,
getReviewRoute,
listReviewsRoute,
} from '../routes/review';
import { createReviewSchema } from '../schemas/review';
const { compose } = primitives;
export const createReviewFeature = compose(
createReviewSchema,
createReviewRoute
);
export const getReviewFeature = getReviewRoute;
export const listReviewsFeature = listReviewsRoute;
5๏ธโฃ Create Serviceโ
Create a review.ts file inside the src/services directory.
The service is a factory function that brings everything together. It takes database connections and returns a complete service ready to be used by Express.
import { partial } from 'lodash';
import { primitives } from '@nodeblocks/backend-sdk';
import {
createReviewFeature,
getReviewFeature,
listReviewsFeature,
} from '../features/review';
const { compose, defService } = primitives;
export const reviewService: primitives.Service = db =>
defService(
partial(
compose(getReviewFeature, createReviewFeature, listReviewsFeature),
{ dataStores: db }
)
);
6๏ธโฃ Use Serviceโ
Finally, create or update your index.ts file in the src directory.
This is where we wire up our review service with Express and MongoDB, creating the actual running application.
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', 'dev', 'user', 'password');
express()
.use(reviewService(await connectToDatabase('reviews'), {})) // {} - empty config
.use(nodeBlocksErrorMiddleware())
.listen(8089, () => console.log('Server running'));
๐งช Testing the Serviceโ
# Create a review (required fields only)
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-d '{"productId":"123","identityId":"6dcdd50a-e0e6-445d-82e1-3da35bc2d149","rating":5}'
# Create a review with optional comment
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-d '{"productId":"123","identityId":"6dcdd50a-e0e6-445d-82e1-3da35bc2d149","rating":5,"comment":"Great product!"}'
# Get a review
curl http://localhost:8089/reviews/[reviewId]
Expected Response:
{
"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"
}
๐ง Troubleshootingโ
Getting HTML Instead of JSON Errors?โ
If your API returns HTML error pages instead of JSON, you likely forgot the error middleware:
const connectToDatabase = withMongo('mongodb://localhost:27017', 'dev', 'user', 'password');
// โ Missing error middleware
express()
.use(reviewService(await connectToDatabase('reviews'), {}))
.listen(8089, () => console.log('Server running'));
// โ
Correct with error middleware
express()
.use(reviewService(await connectToDatabase('reviews'), {}))
.use(nodeBlocksErrorMiddleware()) // Add this line!
.listen(8089, () => console.log('Server running'));
Error Middleware Must Come Lastโ
The error middleware must be added after all your services and routes:
const connectToDatabase = withMongo('mongodb://localhost:27017', 'dev', 'user', 'password');
// โ
Correct order
express()
.use(reviewService(await connectToDatabase('reviews'), {}))
.use(nodeBlocksErrorMiddleware()) // Last!
.listen(8089, () => console.log('Server running'));
โก๏ธ Next Stepsโ
Now you can practice by adding more functionality to your review service. Try implementing the following endpoints that might be required for a review service:
- Update and Delete operations - Add PATCH and DELETE endpoints to modify and remove reviews