Skip to content

basaldev/nodeblocks-service-template

Repository files navigation

nodeblocks-service-template

A nodeblocks service template using a guest order as example


๐Ÿ”ฐ Setup

Install NVM

Set Node Version

nvm use
โ—๏ธ If you are going to deploy to Nodeblocks Cloud, it is important to use NPM package manager and NOT Yarn

๐Ÿ›  Environment Variables

Global Variables

NODEBLOCKS_DEV_TOKEN is required to install private packages. This token will be provided by Nodeblocks team. Set NODEBLOCKS_DEV_TOKEN in your local environment before proceeding.

export NODEBLOCKS_DEV_TOKEN=__INSERT_YOUR_TOKEN_HERE__

Project Variables

Initialize the env file:

cp .env.default .env

Set the correct values in the .env file:

Name Description
AUTH_ENC_SECRET encryption key
AUTH_SIGN_SECRET secret key
DATABASE_URL service db url
PORT port number
CATALOG_ENDPOINT catalog service endpoint
USER_ENDPOINT user service endpoint
ORGANIZATION_ENDPOINT organization service endpoint

โš ๏ธ NOTE

AUTH_ENC_SECRET & AUTH_ENC_SECRET should be the same across all services

๐Ÿช„ Usage

Install project dependencies

npm ci

Run

npm run start:dev

Run Tests

npm run test

Run Unit Tests

npm run test:unit

Run API Tests

npm run test:api

Run Tests in verbose mode (--silent false)

npm run test:verbose

๐Ÿ—‚๏ธ Folder Structure

src
โ”œโ”€โ”€ adapter
โ”‚   โ””โ”€โ”€ guest-orders
โ”‚       โ”œโ”€โ”€ dataServices
โ”‚       โ”œโ”€โ”€ handlers
โ”‚       โ””โ”€โ”€ validators
โ”œโ”€โ”€ helper
โ””โ”€โ”€ tests
    โ”œโ”€โ”€ api
    โ””โ”€โ”€ unit
        โ”œโ”€โ”€ adapter
        โ”‚   โ””โ”€โ”€ guest-orders
        โ”‚       โ”œโ”€โ”€ handlers
        โ”‚       โ””โ”€โ”€ validators
        โ””โ”€โ”€ helper
Folder Description
src source folder
src/adapter adapters
src/adapter/guest-orders guest order adapter files
src/adapter/guest-orders/dataServices guest order data services
src/adapter/guest-orders/handlers guest order handler functions
src/adapter/guest-orders/validators guest order validators
src/helper any utilities or helper functions
src/tests unit and api tests
src/tests/api api tests
src/tests/unit unit tests which should follow the adapter folder structure

๐Ÿ‘ฉ๐Ÿปโ€๐Ÿ”ง Writing an Adapter for a Service

The Adapter is a class that handles the endpoint handlers, validators, and other options and dependencies. Here's an overview of the Adapter Class.

  1. First thing to do is create an Adapter class that implements an existing service. In this guest order example, we create an Adapter class that implements OrderAdapter.

    export class GuestOrderAdapter implements OrderAdapter {...}
  2. Just like the default order adapter, it should take 2 arguments: opts and dependencies.

    constructor(
     opts: GuestOrderAdapterOptions,
     dependencies: defaultAdapter.OrderDefaultAdapterDependencies
    )
  3. The basic class properties should be: adapterName, authSecrets, opts, dependencies, dataServices.

    adapterName = ADAPTER_NAME;
    authSecrets: crypto.AuthSecrets;
    opts: Required<
     Omit<GuestOrderAdapterOptions, keyof crypto.AuthSecrets>
    >;
    dependencies: defaultAdapter.OrderDefaultAdapterDependencies;
    dataServices: {
     guestOrder: GuestOrderDataService;
    };
  4. Create the handlers. These objects should include the handler function itself, and any validators.

    a. The handler function should accept a logger and context as arguments and then return an object with data and status.

    this.getGuestOrder = {
      handler: async (
        logger: Logger,
        context: adapter.AdapterHandlerContext
      ): Promise<adapter.AdapterHandlerResponse> => {
        return await getGuestOrderHandler(
          this.dataServices.guestOrder,
          logger,
          context
        );
      },
      ...
    };
    export async function getGuestOrderHandler(
      guestOrderService: Pick<GuestOrderDataService, 'getOneGuestOrderByOrgId' | 'prepareGuestOrderResponse'>,
      logger: Logger,
      context: adapter.AdapterHandlerContext
    ) {
      const { params } = context;
      ...
      return {
        data: expandedGuestOrder,
        status: util.StatusCodes.OK,
      };
    }

    b. The validators assure that the requests match or follow a set of rules. Common validators are authentication and authorization which make sure the user is logged in and is allowed access to the service. You can also use validators to check if an item exists. For example, you can use authorization to make sure the user is an admin, and if the organization exists in the params.

    validators: {
     authentication: security.createIsAuthenticatedValidator(
       this.authSecrets,
       this.opts.authenticate
     ),
     authorization: this.dependencies.userAPI.createIsAdminUserValidator(
       this.authSecrets
     ),
     organizationExists: partial(
       defaultAdapter.isOrganizationExists,
       { name: 'orgId', type: 'params' },
       this.dependencies.organizationAPI
     ),
    },

    c. If you aren't using any of the standard handlers, you can use the notFoundHandler handler.

    this.createOrder = {
      handler: adapter.notFoundHandler,
      validators: {},
    };

๐ŸฅŒ Creating Handlers

The handlers contain the logic of the endpoints. Here you would retrieve information, manipulate the data, and return the data. You can also throw NBErrors that would send a specific HTTP status code.

Here is the getGuestOrderHandler() function. It basically receives the orderId and the orgId from the params context, fetches the data from the dataService, normalizes and/or expands the data, then returns the data and the HTTP status.

export async function getGuestOrderHandler(
  guestOrderService: Pick<GuestOrderDataService, 'getOneGuestOrderByOrgId' | 'prepareGuestOrderResponse'>,
  logger: Logger,
  context: adapter.AdapterHandlerContext
) {
  logger.info('getGuestOrderHandler');
  const { params } = context;
  const guestOrder = await guestOrderService.getOneGuestOrderByOrgId(params?.orderId, params?.orgId);
  if (!guestOrder) {
    throw new NBError({
      code: defaultAdapter.ErrorCode.notFound,
      httpCode: util.StatusCodes.NOT_FOUND,
      message: 'operation failed to get an order',
    });
  }

  const expandedGuestOrder = await guestOrderService.prepareGuestOrderResponse(
    get(context, 'query.$expand', '').toString(),
    guestOrder
  );

  return {
    data: expandedGuestOrder,
    status: util.StatusCodes.OK,
  };
}

๐Ÿš“ Validators

Validators are basic predicate functions that return OK HTTP status or throws an error. The 2 basic validators are authentication and authorization. You can create your own custom validators too, but below are 2 common validators.

Authentication Validator

A common authentication validator is security.createIsAuthenticatedValidator() and it exists in blocks-backend-sdk. It accepts the authSecrets defined in the environment variables and an authenticate function from blocks-auth-service.

validators: {
  authentication: security.createIsAuthenticatedValidator(
    this.authSecrets,
    this.opts.authenticate
  ),
  ...
},

Authorization Validator

This authorizes the user to access the endpoint. An example of a common authorization validator is the createIsAdminUserValidator() which checks to make sure the user is an admin user.

authorization: this.dependencies.userAPI.createIsAdminUserValidator(
 this.authSecrets
),

Some() validator utility

Found in the blocks-backed-sdk service, you can use this if you want to validate between 2 or more validators. It will return the first successful (200 or 201) status. It will also return all the errors that was thrown. Simple example:

authorization: security.some(
  security.createIsMeValidator(
    userAdapter.authSecrets,
    { name: 'id', type: 'params' },
    userAdapter.opts.authenticate
  ),
  partial(
    defaultAdapter.isAdmin,
    userAdapter.authSecrets,
    userAdapter.opts.authenticate,
    userAdapter.dataServices.user
  ),
  partial(
    isClinic,
    ['010', '001'],
    userAdapter.authSecrets,
    userAdapter.opts.authenticate,
    userAdapter.dataServices.user
  ),
),

๐ŸŽผ Schema Validator

This validation makes sure the user has used the correct params, query, or body fields when accessing an endpoint. We use ajv for schema validation. And you should add this as a validator using the security.createValidRequestPredicate() function in blocks-backend-sdk. Below is a simple schema in ajv and its usage as a validator.

  • Schema example

    export function createSampleItem(
      customFields: util.CustomField[]
    ): JSONSchemaType<CreateSampleItemRequest> {
      return {
        additionalProperties: false,
        properties: {
          customFields: util.createCustomFieldAjvSchemaComponent(customFields),
          sample: { isNotEmpty: true, type: 'string' },
          sampleItems: {
            properties: {
              item: { nullable: true, type: 'string' },
              qty: { nullable: true, type: 'number' },
            },
            type: 'object',
          },
        },
        required: ['sample'],
        type: 'object',
      };
    }
  • Validator example

    validBody: security.createValidRequestPredicate(
      createSampleItem(this.opts.customFields?.sample ?? []),
      'body'
    ),

๐Ÿ› ๏ธ Custom Validator

Aside from the validators mentioned, you can create your own custom validator. You can also use security.some() if you so choose. When creating a custom validator, be sure to return a 200 or 201 successful HTTP response or throw an error. Here's an example:

export async function guestOrderBelongsToOrganization(
  guestOrderService: Pick<GuestOrderDataService, 'getOneOrder'>,
  organizationAPI: Pick<OrganizationDefaultAdapterAPI, 'getOrganizationById'>,
  orgIdTargetField: security.TargetField,
  orderIdTargetField: security.TargetField,
  logger: Logger,
  context: adapter.AdapterHandlerContext
) {
  const organizationId = get(
    context,
    [orgIdTargetField.type, orgIdTargetField.name],
    null
  );
  const organization = await organizationAPI.getOrganizationById(
    organizationId
  );
  if (!organization) {
    throw new NBError({
      code: defaultAdapter.ErrorCode.notFound,
      httpCode: util.StatusCodes.NOT_FOUND,
      message: `orgId ${organizationId} cannot be found`,
    });
  }

  const orderId = get(
    context,
    [orderIdTargetField.type, orderIdTargetField.name],
    null
  );
  const order = await guestOrderService.getOneOrder(orderId);
  if (!order) {
    throw new NBError({
      code: defaultAdapter.ErrorCode.notFound,
      httpCode: util.StatusCodes.NOT_FOUND,
      message: `orderId ${orderId} cannot be found`,
    });
  }

  if (order.organizationId === organization.id) {
    return util.StatusCodes.OK;
  }

  throw new NBError({
    code: defaultAdapter.ErrorCode.noPermission,
    httpCode: util.StatusCodes.FORBIDDEN,
    message: `order: orderId=${orderId} does not belong to organization: orgId=${organizationId}`,
  });
}

๐Ÿ’พ Using a Mongo repository to save data

The Mongo db client should have been passed into the adapter as a dependency. Here is a sample to save data into a Mongo db repository. The create() function accepts the entity to save the data to.

async createOrder(order: GuestOrderCreation): Promise<{ id: string }> {
  return this.guestOrderRepository.create(
    new GuestOrderEntity({
      ...order,
    })
  );
}

โ˜Ž๏ธ Using a Mongo repository to query data

Here is a simple example to query data from a mongo db repository.

async getOneOrder(id: string): Promise<GuestOrderEntity | null> {
  const order = await this.guestOrderRepository.findOne(id);
  return order;
}

๐Ÿ”ฌ Automated tests

We use Jest and supertest for our unit and API tests.

Unit tests for validator

Here is an example of a unit test for a validator. We also mock the different services.

it('should return 200 when product contains variant', async () => {
  mockedCatalogService.getAvailableProducts.mockResolvedValue(dummyAvailableProducts);

  const response = await productContainsVariant(
    mockedCatalogService,
    orgIdTargetField,
    itemsTargetField,
    mockedLogger,
    {
      ...dummyContext,
      params: { orgId: dummyOrganizationId },
      body: { items: [dummyItem] },
    }
  );

  expect(response).toBe(util.StatusCodes.OK);
  expect(mockedCatalogService.getAvailableProducts).toHaveBeenCalledWith({
    queryOptions: {
      expand: 'variants',
      filter: `organizationId eq '${dummyOrganizationId}' and id in ['${dummyProductId}']`,
      top: 1,
    },
  });
});

Unit tests for handler

Here is an example of a unit test for the handler. We also mock different services and this will be an example of a handler throwing an error.

it('should throw an error when guest order is not found', async () => {
  mockedGuestOrderService.getOneGuestOrderByOrgId.mockResolvedValue(undefined);

  await expect(getGuestOrderHandler(
    mockedGuestOrderService,
    mockedLogger,
    {
      ...dummyContext,
      params: {
        orderId: dummyOrderId,
        orgId: dummyOrganizationId,
      },
    }
  )).rejects.toThrow(
    new NBError({
      code: defaultAdapter.ErrorCode.notFound,
      httpCode: util.StatusCodes.NOT_FOUND,
      message: 'operation failed to get an order',
    })
  );
  expect(mockedGuestOrderService.getOneGuestOrderByOrgId).toHaveBeenCalledWith(dummyOrderId, dummyOrganizationId);
  expect(mockedGuestOrderService.prepareGuestOrderResponse).not.toHaveBeenCalled();
});

API tests

API tests use jest and supertest and we don't mock any dependency services. We use mongodb memory server and create and start the dependent services locally. Data is created before using either beforeAll or beforeEach to setup the API tests. In this guest order example, we create an admin user to create an organization, category, product, and product variant before the tests.

it('should return 201 and successfully create a guest order', async () => {
  const guestOrderPayload = {
    items: [{
      productId: product.id,
      variantId: (product.variants[0] as ProductVariantResponse).id,
      quantity: 3
    }],
    customer: dummyCustomer,
  };

  await request(blockServices.guestOrderServer)
    .post(`/orgs/${organization.id}/orders`)
    .set('Accept', 'application/json')
    .send(guestOrderPayload)
    .expect(201);
});

๐Ÿšš Deploying to Nodeblocks Cloud

Here are the prerequisites to deploying your custom service to Nodeblocks Cloud

  • Use NPM instead of Yarn or other package managers.
  • Knowledge of deploying default service.
  • Nodeblocks Dev Token for NPM access.
  • Code in a GitHub repo
  • A Project in Nodeblocks Cloud

How to deploy to Nodeblocks Cloud

  1. In the Editor page at the top, click on Add service.
  2. In the Add service drop down, click on + Custom ....
  3. Enter the Name of your new service.
  4. Enter the SSH Repository URL of the repo.
  5. If you have the code in a different branch than main, then enter the branch.
  6. Click Add.
  7. You will presented with a popup with an ssh-key. You will need to add that key to your github repo under Settings -> Deploy Keys. Read-only permissions is fine.
  8. Once you create the service, go to the section and click on Service Configs.
  9. Here, you will add the environment variables:
    Name Description
    AUTH_ENC_SECRET encryption key
    AUTH_SIGN_SECRET secret key
    DATABASE_URL service db url
    CATALOG_ENDPOINT catalog service endpoint
    USER_ENDPOINT user service endpoint
    ORGANIZATION_ENDPOINT organization service endpoint
    NODEBLOCKS_DEV_TOKEN npm token
  10. Once you enter the service configs, click on the 3 dots in the top right part of the section, and click Deploy.

You can also view the logs to check on deployment.

๐Ÿ“ฎ Using Postman Collection

After deploying or running the service locally, you can use Postman to test your API. Included in this repo is a Postman Collection that you can import. This collection contains variables that you can define. Here are the variables:

Name Description
guest_order_endpoint The endpoint of the guest order.
guest_order_port Port number of the guest order endpoint. If using nodeblocks cloud, this is not necessary.
auth_fingerprint The auth fingerprint for the headers.
auth_access_token The access token for authentication.

You will need an organization with a product and product variant to test the guest order.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published