In my previous post, I wrote about the open-source work at e.GO Mobile.

Today, I would like to go into a little more detail and focus on our controller framework.

For this purpose, I have set up a small example repository with a functional demo on GitHub.

Background & motivations

The background is that in complex backends, patterns such as

app.get("/", async () => {

});

app.post("/foo", async () => {

});

app.delete("/bar", async () => {

});

/// ...

create a lot of confusion.

In addition, developers often like to do their own thing and with so much freedom, they start implementing their own patterns according to their “best knowledge and conscience”, which may be very unfamiliar for other developers.

Other points are that software developers enjoy writing code, but dislike documenting and writing tests.

The goals for us were as follows:

  • endpoints have to be mapped with the directory structure
  • handlers should be encapsulated in classes
  • there have to be at least one test for each handler
  • in addition, there should be documentation for each endpoint

At e.GO, we solved everything quite elegantly in the end with the help of TypeScript, its decorators, and the Swagger UI.

Controllers

As in the previous project, the HTTP module by e.GO is needed, which can be found on NPM:

npm install @egomobile/http-server --save

To begin with, the following directory structure is created, if one is working with TypeScript:

.
├── controllers
│   ├── index.ts
│   ├── index.openapi.ts
│   ├── index.spec.ts
└── index.ts

In the file /index.ts, the code is stored that initializes the server and loads the controller file within the directory /controllers into the context:

const app = createServer();

// load controllers from subfolder
// into context of `app`
app.controllers({
    // take .ts files
    "patterns": "*.+(ts)",
    // scan `controllers` subfolder
    "rootDir": path.join(__dirname, "controllers"),
    // setup Swagger / OpenAPI documentation
    "swagger": {
        "document": {
            "info": {
                "title": "My app",
                "version": "0.0.0"
            }
        },
        "resourcePath": __dirname
    },
    // we do not want to use validation
    // via Swagger / OpenAPI
    "validateWithDocumentation": false
});

// start the server
await app.listen(process.env.PORT);
console.log("App now running on port", app.port);

In the second step, we create a simple controller in the file /controllers/index.ts.

import {
    Controller,
    ControllerBase,
    Describe,
    GET,
    IHttpRequest, IHttpResponse,
    It
} from "@egomobile/http-server"

@Controller()  // mark `MyController` class as "controller class"
@Describe()  // we want for all endpoints in this class tests
export default class MyController extends ControllerBase {
    // define a GET endpoint with relative path `/`
    @GET("/")
    // describe and reference to tests in `index.spec.ts` file ...
    @It("should return 200", Symbol("my test #1"))
    @It("should return 400", Symbol("my test #2"))
    public async getFoo(request: IHttpRequest, response: IHttpResponse) {
        // ...

        if (request.query!.get("bar") === "42") {
            response.writeHead(200, {})
        } else {
            response.writeHead(400, {})
        }
    }
}

Using the Controller() decorator, we “mark” the MyController class in such a way that we want to use its objects as controllers.

Tests

The @Describe() and It() decorators are created for common frameworks like Jest and specify that this class and its methods must have tests and which ones to execute in this case (my test #1).

For this purpose, we also need to create an index.spec.ts in the same directory, where the controller class is located. For this, the file must export an array with the same name as the method within the controller, for which the tests should be executed:

export const getFoo: TestSpecItem[] = [
    {
        "ref": Symbol("my test #1"),
        "expectations": {
            "status": 200
        }
    },

    {
        "ref": Symbol("my test #2"),
        "expectations": {
            "status": 400
        }
    }
];

In combination with the supertest module, we can test each individual endpoint as we would do manually.

Documentation

In addition to the tests, we also require a documentation of the endpoint. For this purpose, we create an index.openapi.ts file in the same directory as the controller class.

Here we use the same pattern again: We export an object with the same name as the method that is to be documented in the form of an OpenAPI compatible operation object.

import type { OpenAPIV3 } from "openapi-types"

export const getFoo: OpenAPIV3.OperationObject = {
    "summary": "Returns a list of foos",
    "parameters": [{
        "description": "A parameter with a secret value",
        "in": "query",
        "name": "bar",
        "required": true
    }],
    "responses": {
        "200": {
            "description": "The list of foos",
            "content": {
                "application/json": {
                    "examples": {
                        "Example #1": {
                            "value": {}
                        }
                    }
                }
            }
        },
        "400": {
            "description": "User input strange things",
            "content": {
                "application/json": {
                    "examples": {
                        "Example #1": {
                            "value": {}
                        }
                    }
                }
            }
        }
    }
}

Parameters

In addition to query parameters, it is also possible to define URL parameters, similar to frameworks like Next.js, through the directory structure.

To do this, we simply have to add an @ symbol before the directory.

For example, one would implement an endpoint /foo/:bar/bazz through the file /foo/@bar/bazz/index.ts:

import {
    Controller,
    ControllerBase,
    Describe,
    IHttpRequest, IHttpResponse,
    It,
    POST
} from "@egomobile/http-server"

@Controller()
@Describe()
export default class BazzController extends ControllerBase {
    @POST("/")
    @It("should return 201", Symbol("my test #1"))
    public async postBazz(request: IHttpRequest, response: IHttpResponse) {
        // ...

        if (request.params!.bar === "42") {
            response.writeHead(201, {})
        } else {
            response.writeHead(400, {})
        }
    }
}

Input validation

Most scenarios in REST API deal with processing input data through the request body.

The controller framework offers two external modules for data validation:

For this purpose, the HTTP module offers 2 middlewares, validate() and validateAjv():

import {
    Controller,
    ControllerBase,
    Describe,
    IHttpRequest, IHttpResponse,
    It,
    json,
    PATCH,
    PUT,
    Use,
    validate,
    validateAjv
} from "@egomobile/http-server"

interface IBazzBody {
    foo: number;
}

const bazzBodyAjvSchema: JSONSchema7 = {
    "type": "object",
    "required": [
        "foo"
    ],
    "properties": {
        "foo": {
            "type": "number",
            "enum": [42]
        }
    }
};

const bazzBodyJoiSchema = schema.object({
    "foo": schema.number().strict().valid(42).required()
}).required()

@Controller()
@Describe()
// for ALL endpoints in this class
// we setup a JSON parser middleware
@Use(json())
export default class BazzController extends ControllerBase {
    @PUT({
        "path": "/",

        // use Joi to validate
        "use": [
            validate(bazzBodyJoiSchema)
        ]
    })
    @It("should return 200", Symbol("my test #1"))
    public async putBazz(request: IHttpRequest, response: IHttpResponse) {
        // from here on we can be sure to have valid data
        const body = request.body as IBazzBody

        // ...
    }

    @PATCH({
        "path": "/",

        // use Ajv to validate
        "use": [
            validateAjv(bazzBodyAjvSchema)
        ]
    })
    @It("should return 200", Symbol("my test #1"))
    public async patchBazz(request: IHttpRequest, response: IHttpResponse) {
        // from here on we can be sure to have valid data
        const body = request.body as IBazzBody

        // ...
    }
}

Import values

With the Dependency Injection pattern, it is possible to import values and objects from any location in the application.

For this purpose, there is the decorator @Import within the HTTP module.

However, before it can be used and values can be imported, these values must be set when initializing the controllers:

/// ...

class TasksRepository {
    async deleteTask(taskId: string): Promise<void> {
        // ...
    }
}

// load controllers from subfolder
// into context of `app`
app.controllers({
    // define the values you would like
    // to make available with @Import()
    "imports": {
        "tasks": new TasksRepository()
    },

    // ...
});

/// ...

In this example, we set a value named tasks that we can later import into any controller:

import {
    Controller,
    ControllerBase,
    DELETE,
    Describe,
    IHttpRequest, IHttpResponse,
    Import,
    It
} from "@egomobile/http-server"

@Controller()
@Describe()
export default class TaskController extends ControllerBase {
    // without and argument in @Import()
    // the property must has the same name as
    // in the initialization
    @Import()
    public readonly tasks!: TasksRepository

    // alternative:
    //
    // if you want a different property name,
    // you need to reference to the value
    // by its name as defined in the initialization
    //
    // @Import("tasks")
    // public readonly taskRepo!: TasksRepository

    @DELETE("/:task_id")
    @It("should return 204", Symbol("my test #1"))
    public async deleteTask(request: IHttpRequest, response: IHttpResponse) {
        const taskId = request.params.task_id;

        await this.tasks.deleteTask(taskId)

        response.writeHead(204, {})
    }
}

Start the demo project

Before you can start the demo project, you have to install Docker with docker-compose.

After this, run

docker-compose up

from the repositorie’s root directory inside a “terminal” and use tools like Postman or REST Client for Visual Studio Code to access the endpoints.

The Swagger documentation will be available at http://localhost:4000/swagger/

Conclusion & outlook

With the help of our in-house development, we at e.GO ensure that our Node.js backends are easy to implement, fast, secure, and stable.

We are also not dependent on external developers here and can adapt everything to our own needs.

Over time, the module has grown and must also adapt to new changes, such as the JavaScript-compatible decorators in TypeScript 5+. For this purpose, we have started a refactoring process, which is currently still in the alpha phase. One main goal is, beside implementing all current features, the separation of these into own modules.

Have fun trying it out! 🎉