Typescript for Strapi! Life's too short to ES5!

Posted in Javascript on Oct 27, 2019 by Alexandru Constantin


If you prefer it, check out the same post on Medium.

Strapi is my headless CMS of choice if I want to build an API for a mobile / web application. It's open source, extensible & built on top of Koa. It also has loads of useful features you would expect from a powerful CMS, like: content manager & model builder, authentication & security features, role & permission management, plugin support and much more.

However, it has one major drawback: it does not support Typescript (as of the writing of this article, it's one of the most popular features on their product board's under consideration section).

Therefore, I had to find a way to make it, at least from a development perspective, support this beautiful flavor of Javascript, which is Typescript.

Intro

I'm using Strapi 3.0.0-beta.16.3. After you install Strapi, create a new project and run it, you'll see a couple of folders in the application's root:

  • admin: the administration module; since beta - if I'm not mistaken - Strapi adopted a more modular approach to deliver its functionality (no business editing anything here)
  • api: here lies the logic of our app, it's where we implement our APIs (it's also what we're interested in for the purpose of this article)
    • config: the API's config (routes, policies etc.)
    • controllers
    • models
    • services: your custom services
  • build: the "compiled" UI of the admin interface
  • config (mostly) JSON config files for the app e.g. database, security etc. (you can also include your custom files & config here)
  • extensions: customization config for your plugins, including admin; read more about it in the official docs
  • node_modules: no need to talk about this usual suspect, I guess
  • plugins: the code for the installed plugins, again modular
  • public: your app's frontend, together with its assets (if it has one)

Also have a look at the file structure in the official docs.

Objective

As I mentioned, what I'm going to focus on is the api, what we want to achieve is: api/**/*.js becomes api/**/*.ts.

Step 1: rewrite your *.js to *.ts

Hopefully you don't have a huge application yet to port to Typescript. Also I hope I don't have to make an argument as of why it's better to write your app in Typescript, as opposed to ES5.

If you do have a large codebase you want to convert or even if you don't you'll find this guide from typescriptlang.org about migrating to Typescript very helpful.

Step 1.1: A place for our shiny new Typescript code

Create an api_ts folder in the root of your application, this is where all your Typescript code will reside.

We will everything, keeping the same folder structure. Let's take one of my APIs as an example, userprofile; this is how the folder structure looks like before rewriting:

my userprofile API before rewriting

And this is how it will look like after (don't worry about creating all the files just now, just have in mind that this is our goal):

my userprofile API after rewriting

Step 1.2: Some boilerplate

If you already have some Strapi code lying around, you'll very often notice there are a lot of function calls on the global strapi object. A convenient feature, but also one Typescript will complain about, since it's not aware of any such global object.

To overcome this, we just need to create a dummy type information file: api_ts/strapi.d.ts; I populated it with the following fields, you may need to add others depending on your code:

declare namespace strapi {
  const config;
  const plugins;
  const services;
  const utils;
}

Step 1.3: (Optional) Generating interfaces from models

You may find this useful: the strapi-to-typescript-2 package provides a simple CLI method that converts your models into Typescript interfaces. You can put those anywhere inside api_ts e.g. inside api_ts/strapi.d.ts.

I added the following line to my package.json scripts:

"gen-ts-interfaces": "sts api/ -o ts/models/"

I personally didn't feel the need to do this, but I may in the future.

Step 1.4: Rewriting controllers

Now, if you're familiar with how Strapi controllers work, you may be a little bit confused: how will Strapi know about my endpoints if I have rewritten the controller modules into classes?

Don't worry, I have a little trick up my sleeve to solve just that. We're going to use good old dependency injection for that, I chose tsyringe:

npm i --save-dev tsyringe

I hinted earlier that controllers will become classes; let's take another concrete example:

Snippet from api/userprofile/config/route.json; our user profile confirm endpoint:

{
  "method": "GET",
  "path": "/confirm_email",
  "handler": "UserProfile.confirm",
  "config": {
    "policies": []
  }
}

Our api/userprofile/controllers/UserProfile.js:

...
const mailService = require('../../message/services/Mail.service');
...
module.exports = {
    confirm: async (ctx) => {
        ...  // the body of the function
    }
}

Becomes api_ts/userprofile/controllers/UserProfile.ts:

...
import 'reflect-metadata';
import { autoInjectable } from 'tsyringe';

import { MailService } from '../../message/services/Mail.service';  // serves purely as injection example

@autoInjectable()  // informs tsyringe that this class needs to have its constructor members injected automatically
class UserProfileController {
    constructor(
        ...
        private mailService?: MailService,  // example: this is how we inject a service
    ) {}
    ...
    async confirm(ctx) {
        ...  // the body of the function
    }
}
module.exports = new UserProfileController();  // VERY IMPORTANT!

Let's break down what we did here, it may look complicated but it's actually very straightforward. After I came up with the approach, it took me under one minute to convert each controller, depending on the amount of code I had to copy paste:

  1. The module becomes a class
  2. The module exports become functions in that class
  3. If we required services / other classes, we can inject them in the constructor
  4. We need to export an instance of the class (this is only necessary for controllers)

By now you're probably skeptical about this: I was exporting an object and now I'm exporting an instance of a class? What is happening here?

Well, the beauty of Javascript is that almost everything is an object. Therefore Strapi will be able to call module.confirm in either of those situations.

Advice from my refactoring experience

Start with controllers when refactoring to Typescript, this will give you the best overview on how you would want to restructure your code and will tell you which components need to be services (classes) and which should only be plain static helper functions.

Just dummy import everthing else, even if it doesn't exist yet, just pretend it does. E.g. in the example above, I wrote this:

import { MailService } from '../../message/services/Mail.service';

before the file actually existed.

In the end, some of my helper functions became services (needed injection) and some of my services became helper functions.

Step 1.5: Rewriting everthing else

You've probably already started this by now. Rewriting the rest of the code is kind of boring & simple. I personally enjoyed it, because I saw first hand my code becoming cleaner.

I called it boring because there are no further tricks involved; Strapi does not care about your services, helper functions or whatever else you want to use in your implementation. Therefore, just go ahead and convert it in any way you please, as long as it's written in Typescript and you import your files correctly, it's fine.

Step 1.6: (Optional) Bonus: rewriting tricky policy functions

In my case, I had a policy function that called a service which required injection. You'll probably be able to extrapolate from the approach I described above. However, I thought it might be useful to include it, since it's a bit different.

This is my api/userprofile/config/polcies/isKnownSender.js:

const userProfileService = require('../../UserProfile.service');

module.exports = async (ctx, next) => {
    const user = await userProfileService.getUserByFacebookSenderId(ctx.request.header['facebooksenderid']);
    ...
}

This is how it looks like after rewriting to api_ts/userprofile/config/polcies/isKnownSender.ts:

import { autoInjectable } from 'tsyringe';
import { UserProfileService } from '../../services/UserProfile.service';

@autoInjectable()
export class IsKnownSenderService {
  constructor(private userProfileService?: UserProfileService) {
  }

  async isKnownSender(ctx, next) {
    const user = await this.userProfileService.getUserByFacebookSenderId(ctx.request.header['facebooksenderid'...
  }
}

const isKnownSenderService = new IsKnownSenderService();
module.exports = isKnownSenderService.isKnownSender.bind(isKnownSenderService);

Notice that we first create an instance of the class, then export the function we need & bind it to the instance.

We need a class because we want to inject the service. We bind the instance because we want to have access to the same this as the class does.

Step 1.7: (Optional) Bonus: should I convert everything to Typescript?

Absolutely not, as I mentioned earlier, it may not come as a surprise that I kept configuration files (e.g. api/config/routes.json) in their original location.

Furthermore, I decided that some APIs did not require rewriting; especially one of them that was exactly in the vanilla form generated by Strapi. I kept that one in ES5, since I'll probably never touch that code.

You may encounter similar situations where you need to decide whether refactoring is the best way to go. What's great is that you don't have to go all the way: if you still want to have some of your code in ES5, and some of it in Typescript, it's totally possible.

I think you'll run into issues when you have dependencies between JS flavors (e.g. want to call something in Typescript from JS and vice versa), but I'm sure you'll find workarounds for that when you need them.

Step 1.8: Gitignoring & removing generated code

You may choose to move everything to api_ts, including json files. I chose to leave everything that did not require typescript inside api, then delete & gitignore everthing that was generated.

If you take a look at the initial folder structure screenshots, you'll notice that some files & folders inside api are yellowed out. That is how my IDE visually informs me that these files are not being tracked by git.

My approach:

  1. Remove all the api/**/*.js files rewtritten ts
  2. Gitignore them, and their generated declaration & map files; my .gitignore:
    /api/**/*.js
    /api/**/*.d.ts
    /api/**/*.js.map
  3. Un-gitignore those controllers you want to keep in ES5, in my case it was the message controller:
    !/api/message/controllers/*
    !/api/message/models/*

Step 2: Set up the Typescript transpiler

We now have all this Typescript code, but we also need to have a way to compile it and make it work with Strapi.

Step 2.1: Make sure you have typescript installed:

npm i --save-dev typescript

Step 2.2: Create the tsconfig.json config file for the Typescript transpiler.

Mine looks like this:

{
  "compilerOptions": {
    "outDir": "./api",
    "target": "es5",
    "declaration": true,
    "removeComments": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "sourceMap": true,
    "noImplicitAny": false
  },
  "include": [
    "./api_ts/**/*"
  ]
}

You may notice it contins a few extra compilerOptions than what you would usually see, let's dive into each of those:

  • outDir: we want our generated js, js.map & d.ts files inside api
  • targed: es5, to be compatible with Strapi
  • declaration: whether to export d.ts files or not; not sure if we need this actually
  • removeComments: pretty self-explanatory
  • experimentalDecorators: required for tsyringe to work
  • emitDecoratorMetadata: required for tsyringe to work
  • sourceMap: whether to export js.map files, required for debugging later
  • noImplicitAny: required for lazy people like me who don't want to type everything, but actually should, because what's the point of Typescript then?

The ./api_ts/**/* under include is just telling tsc that any ts file inside any folder in api_ts needs to be converted.

Step 2.3: Transpile

I added these lines to my package.json scripts:

"build-ts": "tsc",
"watch-ts": "tsc -w"
  • npm run build-ts will transpile once
  • npm run watch-ts will transpile any time there are changes on api_ts/**/*

Step 3: Workaround for Strapi permissions

If you followed the tutorial until this point and tried calling your endpoints you may be surprised to see that you get a 403 Forbidden error response for all of them. Furthermore, they don't even show up in Strapi's permission management.

Long story short, Strapi is not aware that the implementation for these exists. Well actually it is, since it's not spitting out an error informing you that there's no such function, like it would do if you deleted the function completely from the class.

I haven't dug very deep into this issue, but I suspect it's because of the way Strapi generates its permission entities at startup. It's probably just a shortcoming of the users-permissions plugin, since it wasn't designed to work like this.

Not to worry, I have an elegant solution for this as well. There's this file config/functions/bootstrap.js that's called when Strapi boots up, where we can write our custom logic.

I wrote this nifty little piece of code in there.

'use strict';

/**
 * An asynchronous bootstrap function that runs before
 * your application gets started.
 *
 * This gives you an opportunity to set up your data model,
 * run jobs, or perform some special logic.
 *
 * See more details here: https://strapi.io/documentation/3.0.0-beta.x/configurations/configurations.html#bootstrap
 */

const find = require('lodash/find');
const fs = require('fs');

const fixPermissionsForTypescriptControllers = async () => {
  const userPermService = strapi.plugins['users-permissions'].services['userspermissions'];
  const roles = await userPermService.getRoles();

  fs.readdir('api', (err, controllerDirs) => {  // iterating controllers
    controllerDirs.forEach((controller) => {
      const controllerRoutesPath = `api/${controller}/config/routes.json`;
      if (fs.existsSync(controllerRoutesPath)) {
        const controllerRoutes = require(`../../${controllerRoutesPath}`)['routes'];  // getting the route config
        controllerRoutes.forEach((controllerRoute) => {  // using the special "roles" & "enabled" fields
          if (controllerRoute['roles']) {
            const action = controllerRoute['handler'].substring(controllerRoute['handler'].indexOf('.') + 1);
            controllerRoute['roles'].forEach(async (roleName) => {
              const role = find(roles, { name: roleName });
              // manually updating the permissions table to include those methods
              await strapi.query('permission', 'users-permissions').model.updateOne(
                {
                  action,
                  controller,
                  role: role['_id'],
                  type: 'application',
                },
                { enabled: controllerRoute['enabled'], policy: '' },
                { upsert: true },
              );
            });
          }
        });
      }
    });
  });
};

module.exports = async () => {
  await fixPermissionsForTypescriptControllers();
};

What this does is the following:

  • Iterate over all APIs
  • Look for the roles field inside each API's routes.json, for each endpoint. Therefore, for all endpoints and all APIs you still want to use, you'll need those 2 extra custom fields:
    {
    "method": "POST",
    ...
    "roles": ["Public"],
    "enabled": true
    }
  • If roles is present, it will create the corresponding entries in the database, allowing the endpoint to be queried

The roles field tells Strapi for which roleNames it should assign the permissions.

The enabled field tells Strapi whether access should be granted by default.

Important note

This has only been tested with MongoDB as database, it probably won't work with others in this form. You'll have to change the strapi.query part to suit your needs.

Note that this won't work if you want different permissions for different roles. In that case, you'll have to modify the implementation, either passing enabled as an array or creating a whole different type of object altogether.

I'm sure this can be done even more elegantly, but this was the solution I came up with.

(Optional) Step 4: Debugging in Typescript FTW

Thanks to this not-immediately-googleable stackoverflow question, I found a solution for this.

So, I created a server.js file in the root of my application, with the following contents:

const strapi = require('strapi');
strapi({ dir: process.cwd(), autoReload: true }).start();

Then, I added the following lines inside my package.json scripts:

"debug": "nodemon --inspect=0.0.0.0:9229 server.js",
"watch-debug": "concurrently --handle-input \"npm run watch-ts\" \"npm run debug\" "
  • npm run debug starts a debugger
    • Note that you'll have to attach a node.js debugger on port 9229, you can easily do that with your IDE
    • Breakpoints will work in both js & ts code
  • npm run watch-debug is all I need for development: every time something changes in the ts code, it gets transpiled and since Strapi is running in autoReload mode, it will restart to reflect the changes. Beautiful!

Closing thoughts

I'm aware this is not the best, neither the simplest way of integrating Typescript with Strapi. Sometime in the future, Typescript will be fully supported, so all this hassle will no longer be required.

It is good enough & working for us and I hope you can apply some of it, if not all in your current or future Typescript-powered Strapi projects.

For my project, continuing to write in ES5 would have been a dealbreaker, since we may have to wait for months, if not years, for official support (see this GitHub discussion about supporting Typescript). Until then, we'll have such a big codebase that it would be extremely difficult to migrate.


Posted in Javascript on Oct 27, 2019 by Alexandru Constantin