Skip to main content

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 endpoint
  • handler: 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 correctly
  • security.createIsAdminValidator - checks if the request is authenticated and the user is an admin
  • security.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']);

...