How to write a "truly" multitenant microservice in typescript

products versions - Any Cumulocity version

Introduction

Writing a multi-tenant microservice can be tricky as you need to take care of several things:

  • Retrieving credentials of a current tenant to access the API
  • Handle new tenant’s subscription/unsubscription for scenarios that require some initialization/clean-up
  • Handle incoming calls so you can call the Cumulocity API on the right tenant
  • Efficiently managing your data across multiple tenants using cache strategies and so on.

Although the first point is covered by the documentation, the second and third by the microservice Java SDK and the fourth is up to your app logic, when it comes to any other language than Java, you’re kind of naked which might feel a bit uncomfortable.
Not to mention that building the microservice itself can be a tedious task if you want to end up with a file smaller than 100MB.
We’re going to cover all those points in that article except the fourth (because it is more about your capabilities to implement application logic) and I’ll share a typical docker file that should help you build a tight microservice.
And if you’re patient enough, I’ll also cover the same topic for Python in a future article.

Pre-requisite

You’ll need a tenant where the microservice feature is enabled.
Then you can simply start a basic typescript project. I won’t cover that part as you can find plenty of tutorials all around the internet.
Finally, I will assume that you are fluent in typescript.

Steps to follow

One of the most tricky parts is handling tenant subscriptions.
Since there is no event raised when such a thing occurs, you will need 2 specific libraries here:

  • A job scheduler: I will be using node-cron
  • An event bus: events is provided by node.js and I will be using it.

For convenience, I will also be using winston as my logging framework.
Finally, I will be using express as my web app server.

Project dependencies

Here are the dependencies that you should put in your package.json file:

  "dependencies": {
    "@c8y/client": "^1017.0.389",
    "body-parser": "^1.20.2",
    "express": "^4.18.2",
    "node-cron": "^3.0.3",
    "winston": "^3.11.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^20.10.1",
    "@types/node-cron": "^3.0.11",
    "reflect-metadata": "^0.1.13",
    "ts-node": "^10.9.1",
    "typescript": "^5.3.2"
  }

Logger configuration

Let’s first configure our logger. Winston is rather well-documented so I’ll just show up my Logger class here:

import winston, { createLogger, format, transports } from "winston";

export class Logger {
  static getLogger(serviceName: string): winston.Logger {
    return createLogger({
      transports: [new transports.Console()],
      format: format.combine(
        format.colorize(),
        format.timestamp(),
        format.printf(({ timestamp, level, message, service }) => {
          return `[${timestamp}] ${service} ${level}: ${message}`;
        })
      ),
      defaultMeta: {
        service: serviceName,
      },
    });
  }
}

Handling microservice subscriptions

We will be writing a class that handles tenant subscriptions and incoming calls.
This class needs to extend the EventEmitter class:

import { Client, BasicAuth } from "@c8y/client";
import { Request } from "express";
import { EventEmitter } from "events";
import cron from "node-cron";
import { Logger } from "./Logger";

export class MicroserviceSubscriptionService extends EventEmitter {
  // Those are the bootstrap credentials
  // retrieved from the environment variables passed to our microservice
  protected baseUrl: string = process.env.C8Y_BASEURL;
  protected tenant: string = process.env.C8Y_BOOTSTRAP_TENANT;
  protected user: string = process.env.C8Y_BOOTSTRAP_USER;
  protected password: string = process.env.C8Y_BOOTSTRAP_PASSWORD;
  // This is the map of fetchClients for each tenant that subscribes to our microservice
  protected clients: Map<string, Client> = new Map<string, Client>();
  // Our Winston logger
  protected logger = Logger.getLogger("MicroserviceSubscriptionService");

The constructor will then start the scheduled task that handles the subscriptions to our microservice:

  constructor() {
    super();
    // Let's create a schedule task that will handle tenant subscriptions every 10 seconds
    cron.schedule("*/10 * * * * *", () => {
      this.getUsers();
    });
  }

And here is the method that will be responsible for handling the subscriptions:

  protected async getUsers() {
    // Retrieve tenant subscription by calling the application API
    let allUsers = await Client.getMicroserviceSubscriptions(
      { tenant: this.tenant, user: this.user, password: this.password },
      this.baseUrl
    );
    if (allUsers) {
      let registeredTenants = Array.from(this.clients.keys());
      this.clients = new Map<string, Client>();
      allUsers.forEach(async (user) => {
        if (!registeredTenants.includes(user.tenant)) {
          // This is a new tenant subscription
          // Let's retrieve this tenant credentials
          let client = await Client.authenticate(user, this.baseUrl);
          this.clients.set(user.tenant, client);
          this.logger.info(`Tenant ${user.tenant} subscription detected.`);

          this.emit("newTenantSubscription", client);
        } else {
          this.clients.set(user.tenant, this.clients.get(user.tenant));
        }
      });
    }
  }

The last part of our service is the public method that will be used in our microservice to get the current tenant’s fetchClient.
Since we will be using express as our server, I’ll be passing a Request object as an input parameter:

  getClient(request: Request): Promise<Client> {
    // In this naive approach we only consider basic authentication
    // You will need to adapt it for cases that use JWT, etc...
    this.logger.info("Authorization: " + request.headers.authorization);
    let currentTenant: string = Buffer.from(
      request.headers.authorization.split(" ")[1],
      "base64"
    )
      .toString("binary")
      .split("/")[0];
    this.logger.info("Current Tenant: " + currentTenant);
    let client: Client = this.clients.get(currentTenant);
    return new Promise<Client>((resolve, reject) => {
      if (client) {
        resolve(client);
      } else {
        reject(
          new Error(
            `Tenant ${currentTenant} didn't subscribe to this microservice!`
          )
        );
      }
    });
  }

Writing your API

Let’s create a class that will handle our API:

export class MyAPI{
  // The express web app
  app: express.Application = express();
  PORT = process.env.PORT || 80;
  // The logger
  protected logger = Logger.getLogger("MyAPI");

We will then inject our MicroserviceSubscriptionService in the constructor where we will handle any new subscription (newTenantSubscription event):

  constructor(subscriptionService: MicroserviceSubscriptionService) {
    subscriptionService?.on(
      "newTenantSubscription",
      async (client: Client) => {
        // Put your initialization logic here
        // Use client to access the Cumulocity API with current tenant credentials
      }
    );

Still in the constructor, you can configure the express app like this:

    this.app.use(express.json());
    this.app.use(express.urlencoded({ extended: false }));
    this.app.post("/myniceresource", async (req, res) => {
      let client = await subscriptionService.getClient(req);
      try {
        // Call some Cumulocity API here with the client
        res.send("OK");
      } catch (e) { // Example of error catching
        this.logger.error(JSON.stringify(e));
        res.status(405).send(e);
      }
    });

    this.app.listen(this.PORT, () =>
      this.logger.info(`Now listening on port ${this.PORT}!`)
    );

Finally, we just need an index.ts to wrap things together which should be rather straightforward:

import { MyAPI} from "./MyAPI";
import { MicroserviceSubscriptionService } from "./common/MicroserviceSubscriptionService";

new MyAPI(new MicroserviceSubscriptionService());

Build your microservice

A common mistake when building your node.js microservice is to write a docker file that will simply get the source, compile them and run node.js with the index.js file.
This is wrong because you will end up with a lot of unnecessary files:

  • The typescript sources
  • The dev dependencies, which might especially take up a lot of space

To avoid that, you need a docker file that will create your image in 2 steps, like this:

FROM node:alpine as build

WORKDIR /usr/app

COPY package*.json ./
RUN npm ci
COPY ./src ./src
COPY ./tsconfig.json ./
RUN npm install typescript -g && tsc

FROM node:alpine

ENV NODE_ENV production

WORKDIR /usr/app

COPY package*.json ./
RUN npm ci --production
COPY --from=build /usr/app/dist ./dist

RUN npm install pm2 -g

CMD ["pm2-runtime", "dist/index.js"]

Note that I’m using pm2 which is not mandatory.

Don’t forget your cumulocity.json metadata file:

{
    "apiVersion": "2",
    "version": "0.1-SNAPSHOT",
    "provider": {
        "name": "Software AG"
    },
    "isolation": "MULTI_TENANT",
    "requiredRoles": [
        "ROLE_INVENTORY_READ",
        "ROLE_INVENTORY_ADMIN",
        "ROLE_INVENTORY_CREATE",
        "ROLE_EVENT_READ",
        "ROLE_EVENT_ADMIN",
        "ROLE_ALARM_READ",
        "ROLE_ALARM_ADMIN",
        "ROLE_IDENTITY_READ",
        "ROLE_IDENTITY_ADMIN",
        "ROLE_DEVICE_CONTROL_READ",
        "ROLE_DEVICE_CONTROL_ADMIN",
        "ROLE_MEASUREMENT_READ",
        "ROLE_MEASUREMENT_ADMIN"
    ],
    "roles": []
}

Adapt the required roles accordingly.

Finally, here is a little example script to build the microservice:

rm image.tar
rm mymicroservice.zip
docker build . -t mymicroservice
docker save mymicroservice -o image.tar
zip mymicroservice image.tar cumulocity.json

Next steps

You should end up with a file mymicroservice.zip that shouldn’t be bigger than 60MB.
You can now upload it to your tenant and enjoy!


This article is part of the TECHniques newsletter blog - technical tips and tricks for the Software AG community. Subscribe to receive our quarterly updates or read the latest issue.

5 Likes