How to add a new route

A tutorial describing how to add a new route to the Dojo API.

Introduction

This tutorial describes how to add a new route to the Dojo API. For that we take two existing routes and describe his implementation. This route allow a member of the teaching staff of an assignment to publish / unpublish it.

Prerequisites

All the prerequisites are described in How to setup your development environment tutorial.

Properties of the new route

Description of the route

  • Route : /assignments/:assignmentNameOrUrl/publish
  • Verb : PATCH
  • Resume : Publish an assignment
  • Protection type : Clients_Token
  • Protection : TeachingStaff of the assignment or Admin role

Params of the request (url params)

  • Name : assignmentNameOrUrl
  • Description : The name or the url of an assignment.
  • Location : Query
  • Required : Yes
  • Data type : string (path)

Possible Response(s)

  • Code : 200

  • Description : OK

  • Content of the response :

    {
    "timestamp": "1992-09-30T19:00:00.000Z",
    "code": 200,
    "description": "OK",
    "sessionToken": "JWT token (for content, see schema named 'SessionTokenJWT')",
    "data": {}
    }
    
  • Code : 401 - Standard

  • Code : 404 - Standard

Routes files structure

The routes files are located in the src/routes folder. All routes files are named with the following pattern: SubjectRoutes.ts where Subject has to be replaced by the general subject of the routes implemented in it (f.e. Exercise, Assignment, Session, etc.).

Application to our use case

In our case we will add our route to the file with the following path : src/routes/AssignmentRoutes.ts.

Routes class inheritance

All routes files must inherit from the RoutesManager interface. This interface is located in the src/express/RoutesManager.ts file.

When you inherit from this interface you will need to implement the following method :

  • registerOnBackend(backend: Express): void;: This method get the express backend object for register new routes.

Apply to our use case

Now, the src/routes/AssignmentRoutes.ts file will look like this:

import RoutesManager from '../express/RoutesManager';

class AssignmentRoutes implements RoutesManager {
    protected commandName: string = 'publish';

    registerOnBackend(backend: Express) {
    ...
        backend.patch('/assignments/:assignmentNameOrUrl/publish', ..., this.publishAssignment.bind(this));
        backend.patch('/assignments/:assignmentNameOrUrl/unpublish', ..., this.unpublishAssignment.bind(this));
    }

    protected async commandAction(assignmentNameOrUrl: string, options: { force: boolean }): Promise<void> {}
    
    private async publishAssignment(req: express.Request, res: express.Response) {
        return this.changeAssignmentPublishedStatus(true)(req, res);
    }

    private async unpublishAssignment(req: express.Request, res: express.Response) {
        return this.changeAssignmentPublishedStatus(false)(req, res);
    }

    private changeAssignmentPublishedStatus(publish: boolean): (req: express.Request, res: express.Response) => Promise<void> {
        return async (req: express.Request, res: express.Response): Promise<void> => {
        ...
        };
    }
}

export default new AssignmentRoutes();

Define request param binding

The goal of the step is to define a generic request parameter and bind functions that will complete a property in the request.boundParams object. For exemple, we can define a function that will take the assignmentNameOrUrl parameter and find the assignment corresponding to it, add it to the request object and call the next function.

Steps :

  1. First of all, we need to add the new property to the type located in the src/types/express/index.d.ts file in the boundParams object with type: Type | undefined with Type is the type of the param.

  2. In the src/middlewares/ParamsClallbackManager.ts file we need to:

    1. Add the new property to the boundParams object in the initBoundParams method with the undefined value.
    2. Call the the listenParam method in the registerOnBackend method for each paramer as follows:
    this.listenParam(paramName: string, backend: Express, getFunction: GetFunction, arrayOfArgsForGetFunction: Array<unknown>, boundParamsIndexName: string
    

Application to our use case

  1. In the src/types/express/index.d.ts file:
declare global {
    namespace Express {
        interface Request {
            ...
            boundParams: {
                ...
                assignment: Assignment | undefined
            };
        }
    }
}
  1. In the src/middlewares/ParamsClallbackManager.ts file:
...
initBoundParams(req: express.Request) {
    if ( !req.boundParams ) {
        req.boundParams = {
            ...,
            assignment: undefined
        };
    }
}

registerOnBackend(backend: Express) {
...

    this.listenParam('assignmentNameOrUrl', backend, (AssignmentManager.get as GetFunction).bind(AssignmentManager), [ {
        exercises: true,
        staff    : true
    } ], 'assignment');
}

Add security to the route

A middleware is available to check permissions. It is located in the src/middlewares/SecurityMiddleware.ts file. This file does not need to be edited unless you want to add some new security tests.

You can use the function SecurityMiddleware.check(checkIfConnected: boolean, ...checkTypes: Array<SecurityCheckType>) function as a middleware in the route definition.

You have the possibility to just check if the user is connected with the first parameter. If you want to add a more specific check you can add some parameters with the SecurityCheckType enum value (f.e. teaching staff, assignment staff, etc.).

WARNING: The SecurityCheckType args array is interpreted as an OR condition. So if you call: SecurityMiddleware.check(true, SecurityCheckType.TEACHING_STAFF, SecurityCheckType.ASSIGNMENT_STAFF), the middleware will check if the user is connected and if he have the teaching staff role or in the assignment staff.

Application to our use case

For our routes we want to test if the user is connected and if he is in the staff of the assignment. So we will complete the routes definitions like this:

registerOnBackend(backend: Express) {
    ...
    backend.patch('/assignments/:assignmentNameOrUrl/publish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.publishAssignment.bind(this));
    backend.patch('/assignments/:assignmentNameOrUrl/unpublish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.unpublishAssignment.bind(this));
}

Control the request body

The Dojo API use the express-validator (based on validator.js) library to validate and sanitize the request body of requests. We use the last version of the library that is 7.0.1. The documentation is available at the following link: express-validator.

Application to our use case

This tutorial will not go deeper in the express-validator library because we do not need have request body in our routes. But if you need to use it, you can find examples in src/routes/ExerciseRoutes.ts or src/routes/AssignmentRoutes.ts file (look at the ExpressValidator.Schema objects and their usage).

Define the action

When you define an action you define a function with the following signature:

(req: express.Request, res: express.Response): Promise<void>

To respond to the request you need to use the following method (works even if the user is not connected):

return req.session.sendResponse(res: express.Response, code: number, data?: unknown, descriptionOverride?: string, internalCode?: number)

Where :

  • res is the express response object
  • code is the HTTP status code of the response
  • data is the data to send in the response (it must be a JSON serializable value)
  • descriptionOverride is a field that you can use to give a custom string description of the response (by default it will be the description of the HTTP status code)
  • internalCode is the internal code of the response (if you want to add a custom internal code to be more specific than the HTTP status code)

Application to our use case

For the implementation we will split our code into four parts:

  1. If we want to publish, we check first if it is possible (if the last pipeline status is success).
  2. Change the project visibility on Gitlab.
  3. Change the assignment published status on the Dojo database.
  4. Send the response to the client.
// Part 1
if ( publish ) {
    const isPublishable = await SharedAssignmentHelper.isPublishable(req.boundParams.assignment!.gitlabId);
    if ( !isPublishable.isPublishable ) {
        return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, { lastPipeline: isPublishable.lastPipeline }, isPublishable.status?.message, isPublishable.status?.code);
    }
}

try {
    // Part 2
    await GitlabManager.changeRepositoryVisibility(req.boundParams.assignment!.gitlabId, publish ? GitlabVisibility.INTERNAL : GitlabVisibility.PRIVATE);

    // Part 3
    await db.assignment.update({
                                   where: {
                                       name: req.boundParams.assignment!.name
                                   },
                                   data : {
                                       published: publish
                                   }
                               });

    // Part 4
    req.session.sendResponse(res, StatusCodes.OK);
} catch ( error ) {
    if ( error instanceof AxiosError ) {
        res.status(error.response?.status ?? HttpStatusCode.InternalServerError).send();
        return;
    }

    logger.error(error);
    res.status(StatusCodes.INTERNAL_SERVER_ERROR).send();
}

Documentation

Finally, the fun part. We need to document our new created routes. For that we use the OpenAPI specification in his 3.1.0 version.

The documentation is yaml formatted and is in the assets/OpenAPI/OpenAPI.yml file.

Use case: final code

AssignmentRoutes.ts

import { Express }                    from 'express-serve-static-core';
import express                        from 'express';
import { StatusCodes }                from 'http-status-codes';
import RoutesManager                  from '../express/RoutesManager';
import SecurityMiddleware             from '../middlewares/SecurityMiddleware';
import SecurityCheckType              from '../types/SecurityCheckType';
import GitlabManager                  from '../managers/GitlabManager';
import { AxiosError, HttpStatusCode } from 'axios';
import logger                         from '../shared/logging/WinstonLogger';
import db                             from '../helpers/DatabaseHelper';
import GitlabVisibility               from '../shared/types/Gitlab/GitlabVisibility';
import SharedAssignmentHelper         from '../shared/helpers/Dojo/SharedAssignmentHelper';


class AssignmentRoutes implements RoutesManager {
    registerOnBackend(backend: Express) {
        backend.patch('/assignments/:assignmentNameOrUrl/publish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.publishAssignment.bind(this));
        backend.patch('/assignments/:assignmentNameOrUrl/unpublish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.unpublishAssignment.bind(this));
    }

    private async publishAssignment(req: express.Request, res: express.Response) {
        return this.changeAssignmentPublishedStatus(true)(req, res);
    }

    private async unpublishAssignment(req: express.Request, res: express.Response) {
        return this.changeAssignmentPublishedStatus(false)(req, res);
    }

    private changeAssignmentPublishedStatus(publish: boolean): (req: express.Request, res: express.Response) => Promise<void> {
        return async (req: express.Request, res: express.Response): Promise<void> => {
            if ( publish ) {
                const isPublishable = await SharedAssignmentHelper.isPublishable(req.boundParams.assignment!.gitlabId);
                if ( !isPublishable.isPublishable ) {
                    return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, { lastPipeline: isPublishable.lastPipeline }, isPublishable.status?.message, isPublishable.status?.code);
                }
            }

            try {
                await GitlabManager.changeRepositoryVisibility(req.boundParams.assignment!.gitlabId, publish ? GitlabVisibility.INTERNAL : GitlabVisibility.PRIVATE);

                await db.assignment.update({
                                               where: {
                                                   name: req.boundParams.assignment!.name
                                               },
                                               data : {
                                                   published: publish
                                               }
                                           });

                req.session.sendResponse(res, StatusCodes.OK);
            } catch ( error ) {
                if ( error instanceof AxiosError ) {
                    res.status(error.response?.status ?? HttpStatusCode.InternalServerError).send();
                    return;
                }

                logger.error(error);
                res.status(StatusCodes.INTERNAL_SERVER_ERROR).send();
            }
        };
    }
}

export default new AssignmentRoutes();

src/types/express/index.d.ts

import Session                  from '../../controllers/Session';
import { Assignment, Exercise } from '../DatabaseTypes';

// to make the file a module and avoid the TypeScript error
export {};

declare global {
    namespace Express {
        export interface Request {
            session: Session,
            boundParams: {
                assignment: Assignment | undefined, exercise: Exercise | undefined
            }
        }
    }
}

ParamsCallbackManager.ts

import { Express }       from 'express-serve-static-core';
import express           from 'express';
import { StatusCodes }   from 'http-status-codes';
import AssignmentManager from '../managers/AssignmentManager';

type GetFunction = (id: string | number, ...args: Array<unknown>) => Promise<unknown>

class ParamsCallbackManager {
    protected listenParam(paramName: string, backend: Express, getFunction: GetFunction, args: Array<unknown>, indexName: string) {
        backend.param(paramName, (req: express.Request, res: express.Response, next: express.NextFunction, id: string | number) => {
            getFunction(id, ...args).then(result => {
                if ( result ) {
                    this.initBoundParams(req);
                    (req.boundParams as Record<string, unknown>)[indexName] = result;

                    next();
                } else {
                    req.session.sendResponse(res, StatusCodes.NOT_FOUND, {}, 'Param bounding failed: ' + paramName);
                }
            });
        });
    }

    initBoundParams(req: express.Request) {
        if ( !req.boundParams ) {
            req.boundParams = {
                assignment: undefined,
                exercise  : undefined
            };
        }
    }

    registerOnBackend(backend: Express) {
        this.listenParam('assignmentNameOrUrl', backend, (AssignmentManager.get as GetFunction).bind(AssignmentManager), [ {
            exercises: true,
            staff    : true
        } ], 'assignment');

    ...
    }
}

export default new ParamsCallbackManager();