Customizing Adapters
It is common to need to customize adapters in various ways to match your business logic. Nodeblocks provides helpful default behaviours out of the box, but you may need to:
- add new endpoints to blocks
- add new custom fields
- replace mail provider or other third party services
- customize handlers and validators for an existing endpoint
At the current point in time, customizing adapters is done via code. We provide functions on the
@basaldev/blocks-backend-sdk
that you can use to customize your adapter logic to match your business
requirements.
Adding new API endpoints
For each service, we provide a set of default endpoints. However, you may need to add new endpoints to
implement new functionality or features. This can be done by providing an array of customRoutes
when running
startService
for each nodeblocks service.
Common use cases:
- Implementing a new feature not provided by nodeblocks
- Integrating with a third party service
- Adding an alternate means to access an existing feature (e.g., transforming data to a different format, or providing limited access to other users
import {
createNodeblocksCatalogApp,
defaultAdapter,
} from '@basaldev/blocks-catalog-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
// Create an instance of a nodeblocks catalog server
const server = createNodeblocksCatalogApp();
// Create your adapter as a default adapter
const adapter = await defaultAdapter.createCatalogDefaultAdapter({ ... }, { ... });
// Define some custom routes
const customRoutes = [
{
handler: async (logger: Logger, context: adapter.AdapterHandlerContext) => {
logger.info('Getting coupons for user...', context.params.userId);
return {
data: ...,
status: 200
}
},
method: 'post' as const,
path: '/coupons/use',
validators: {
foo: (logger: Logger, context: sdkAdapter.AdapterHandlerContext) => {
if (!context.params?.userId) {
throw new NBError({
code: 'invalid_request',
httpCode: 400,
message: 'userId is required',
});
}
// TODO Some custom validation
return { status: 200 };
},
}
},
];
// Start the service
await server.startService({
...
customRoutes,
});
For each route, you must define the following:
path
: the path of the endpoint. Use:param
to define a path parameter (e.g.,/users/:userId
)method
: the HTTP method of the endpointhandler
: the function that will be called when the endpoint is hit.- This function recieves a logger and a context object, which contains the request and response objects, as well as any parameters or body data.
- To call database functions, access
adapter.dataServices.<service>.<function>
validators
: an object containing functions that will be called to validate the request. These are run before the handler, and if any of them throw an error, the request will be rejected without calling the handler function.- Validators are run from top to bottom, and the first one to throw an error will be returned to the client.
- Validators have a key that is the name of the validator, which can be anything you want.
- These functions should throw an NBError with the appropriate http status if the request is invalid.
Useful validation methods
@basaldev/blocks-backend-sdk
provides a number of useful validation methods that you can use in your custom routes:
security.createIsAuthenticatedValidator
- checks if the request is authenticated correctlysecurity.createIsAdminValidator
- checks if the request is authenticated and the user is an adminsecurity.some
- given a list of validators, checks if at least one of of them passes
Similarly, the external user and organization apis (adapter.dependencies.<service>Api.<function>
) can be used if other microservices need to be accessed from your validators or handlers.
Adding new custom fields
Often, instead of completely changing the functionality of endpoints, you may want to add new fields to existing object schemas.
This can be done by providing a customFields
object when creating an adapter.
Common use cases:
- Modifying the schemas provided by nodeblocks
- Adding links between data in one service to data in another service
import {
createNodeblocksCatalogApp,
defaultAdapter,
} from '@basaldev/blocks-catalog-service';
const adapter = await defaultAdapter.createCatalogDefaultAdapter({
customFields: {
// Custom fields on the Attribute data type
attribute: [
{
name: 'attribute_internal_id',
type: 'string' as const,
},
],
// Custom fields on the Product data type
product: [
{
name: 'is_promoted',
type: 'boolean' as const,
},
{
name: 'tags',
type: 'array' as const,
},
{
name: 'view_count',
type: 'number' as const,
},
{
name: 'metadata',
type: 'object' as const,
},
{
name: 'last_viewed',
type: 'date-time' as const,
},
],
},
}, { ... });
Supported custom field types are:
string
number
boolean
array
object
date-time
date
Custom fields can be used in the same way as regular fields in the API, and will be returned in the response as customFields.<name>
. They can be filtered and ordered by the same way as any other field.
Example: Expanders
Custom field configuration also supports a expander
property, which is a function that will be called to expand the custom field when it is requested. This can be used to fetch data from other services, or to perform complex calculations.
For example, if the catalog API for products is requested with ?expand=endorsed_by_user_id
, the expander
function will be called with the list of user ids, and should return the expanded users for each id:
const adapter = await defaultAdapter.createCatalogDefaultAdapter({
customFields: {
product: [
{
name: 'endorsed_by_user_id',
type: 'string' as const,
expander: async (ids: string[]) => {
// Fetch data from another service
const result = await userApi.getUsers(ids);
return result;
},
},
],
},
}, { ... });
Replacing third party services
Nodeblocks services use a number of third party APIs out of the box, in addition to the internal communication between microservices. These dependencies are injected as the second parameter when creating the adapter, and can be replaced with any class that implements the same interface.
Common use cases:
- Replacing the mail provider with a different service
- Integrating a microservice from Nodeblocks with your own custom APIs
Example: Customizing the mail provider
import {
createNodeblocksUserApp,
defaultAdapter,
} from '@basaldev/blocks-user-service';
import { external } from '@basaldev/blocks-backend-sdk';
class CustomMailProvider implements external.mail.MailService {
sendMail(mailData: external.mail.MailData, opts?: external.mail.MailOptions): Promise<boolean> {
// Custom mail sending logic
return true;
}
}
const adapter = await defaultAdapter.createUserDefaultAdapter({ ... }, {
...
mailService: new CustomMailProvider(),
});
...
Example: Customizing User API for a service
import {
createNodeblocksCatalogApp,
defaultAdapter,
} from '@basaldev/blocks-catalog-service';
import { UserDefaultAdapterApi, RequestContext, UserResponse} from '@basaldev/blocks-default-adapter-api';
class CustomUserApi implements UserDefaultAdapterApi {
async getUserById(
userId: string,
requestContext?: RequestContext
): Promise<UserResponse | undefined> {
// Custom user fetching logic
}
async createUser(
user: CreateUserDto,
requestContext?: RequestContext
): Promise<UserResponse> {
// Custom user creation logic
}
...
}
const adapter = await defaultAdapter.createCatalogDefaultAdapter({ ... }, {
...
userApi: new CustomUserApi(),
});
...
Customizing handlers and validators for an existing endpoint
In many cases, you will want to use an existing endpoint, but adjust the behaviour of the handler (the function that is called when the endpoint is hit) or the validators (the functions that are called to check if the request is valid) to match your business logic.
Common use cases:
- Adding custom validation to an existing endpoint (e.g. checking if a field is unique)
- Removing existing validation from an endpoint
- Modifying the behaviour of an existing endpoint
- Sending a notification when an endpoint is hit
Example: Add a new validation rule to an endpoint
In this example, a new validator called nameUnique
is added to the createUser
endpoint:
import {
createNodeblocksUserApp,
defaultAdapter,
} from '@basaldev/blocks-user-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
let adapter = await defaultAdapter.createUserDefaultAdapter({ ... }, { ... });
adapter = sdkAdapter.setValidator(adapter, 'createUser', 'nameUnique', async (logger, context) => {
const existingUsers = await adapter.dataServices.user.findUsers(logger, { name: context.body.name });
if (existingUsers.length > 0) {
throw new sdkAdapter.NBError({
code: 'invalid_request',
httpCode: 400,
message: 'Name already in use',
});
}
return { status: 200 };
});
...
Example: Replace an existing validation rule with custom logic
setValidator
can be used to replace validators too.
In this example, the updateOrder
endpoint is updated to replace the existing isValidOrderStatus
validator with a custom one:
import {
createNodeblocksOrderApp,
defaultAdapter,
} from '@basaldev/blocks-order-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
let adapter = await defaultAdapter.createOrderDefaultAdapter({ ... }, { ... });
const validStatuses = [
'ACCEPTED',
'CANCELLED',
'CLOSED',
'PENDING',
'PROCESSING',
'YOUR_NEW_STATUS',
];
adapter = sdkAdapter.setValidator(adapter, 'updateOrder', 'isValidOrderStatus', async (logger, context) => {
if (!validStatuses.includes(context.body.status)) {
throw new sdkAdapter.NBError({
code: 'invalid_request',
httpCode: 400,
message: 'Order status is not one of the allowed statuses',
});
}
return { status: 200 };
});
...
Example: Remove validation from an endpoint
In this example, the listUsers
endpoint is updated to remove the authorization
validator requirement:
import {
createNodeblocksUserApp,
defaultAdapter,
} from '@basaldev/blocks-user-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
let adapter = await defaultAdapter.createUserDefaultAdapter({ ... }, { ... });
adapter = sdkAdapter.removeValidator(adapter, 'listUsers', 'authorization');
...
Example: Modifying schema validation for an endpoint
In nodeblocks, we use ajv to validate the request body and query parameters. You can modify the schema validation for an endpoint using the modifySchemaValidator
function. This allows for powerful changes to the schema validation
that go beyond simple custom fields.
In this example, the createUser
endpoint is updated to modify the schema validation to support a new field age
:
import {
createNodeblocksUserApp,
defaultAdapter,
} from '@basaldev/blocks-user-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
import { merge } from 'lodash';
let adapter = await defaultAdapter.createUserDefaultAdapter({ ... }, { ... });
adapter = sdkAdapter.modifySchemaValidator(adapter, 'createUser', 'validBody', (schema) => ({
...schema,
properties: {
...schema.properties,
age: { type: 'number' },
},
required: [...schema.required, 'age'],
}));
...
Example: Modifying the behaviour of an existing endpoint
In this example, the createUser
endpoint is updated to also return data from another service:
import {
createNodeblocksUserApp,
defaultAdapter,
} from '@basaldev/blocks-user-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
let adapter = await defaultAdapter.createUserDefaultAdapter({ ... }, { ... });
adapter = sdkAdapter.modifyHandler(adapter, 'createUser', async (oldHandler) => {
return (logger: Logger, context: adapter.AdapterHandlerContext) => {
const user = await oldHandler(logger, context);
const otherData = await adapter.dependencies.otherServiceApi.getOtherData(user.id);
return {
...user,
otherData,
};
};
});
...
Example: Sending a notification or other side effect when an endpoint is hit
In this example, the createUser
endpoint is updated to send an email when a new user is created:
import {
createNodeblocksUserApp,
defaultAdapter,
} from '@basaldev/blocks-user-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
let adapter = await defaultAdapter.createUserDefaultAdapter({ ... }, { ... });
adapter = sdkAdapter.addHandlerSideEffect(adapter, 'createUser', async (logger, context, response) => {
await adapter.dependencies.mailService.sendMail({
from: 'no-reply@company.com',
to: 'admin@company.com',
subject: 'New user created',
text: `A new user was created with the name ${response.data.name}`,
});
});
...
Side effects are run AFTER the handler function. Sometimes, it is neccessary to preload some information before
sending the side effect. For example, when sending a notification about modifying an order status, you may want to preload
the order data before the status is updated, so that the notification only fires when the status is modified. This can be done
by passing a fourth parameter to addHandlerSideEffect
:
adapter = sdkAdapter.addHandlerSideEffect(
adapter,
'updateOrder',
async (logger, context, response, preloadedData) => {
if (response.data.status !== preloadedData.status) {
// Send notification
}
return { status: 200 };
},
// This is a preload function that will be run before the handler. Data here will be passed into the side effect as `preloadedData`
async (logger, context) => {
return adapter.dataServices.order.getOrder(logger, context.params.id);
}
);
Example: Disable an endpoint
In this example, an endpoint is updated to return a 404 error when it is hit:
import {
createNodeblocksUserApp,
defaultAdapter,
} from '@basaldev/blocks-user-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
let adapter = await defaultAdapter.createUserDefaultAdapter({ ... }, { ... });
adapter = sdkAdapter.disableAdapterMethod(adapter, 'createUser');
...
Example: Set list of enabled endpoints
In this example, the list of enabled endpoints is set to only include the createUser
and getUser
endpoints. All other endpoints will return a 404 error:
import {
createNodeblocksUserApp,
defaultAdapter,
} from '@basaldev/blocks-user-service';
import { adapter as sdkAdapter } from '@basaldev/blocks-backend-sdk';
let adapter = await defaultAdapter.createUserDefaultAdapter({ ... }, { ... });
adapter = sdkAdapter.setEnabledAdapterMethods(adapter, ['createUser', 'getUser']);
...