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/publishVerb :
PATCHResume :
Publish an assignmentProtection type :
Clients_TokenProtection :
TeachingStaff of the assignment or Admin role
Params of the request (url params)
Name :
assignmentNameOrUrlDescription :
The name or the url of an assignment.Location :
QueryRequired :
YesData type :
string (path)
Possible Response(s)
Code :
200Description :
OKContent 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 - StandardCode :
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 :
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
withType
is the type of the param.In the
src/middlewares/ParamsClallbackManager.ts
file we need to:- Add the new property to the
boundParams
object in theinitBoundParams
method with theundefined
value. - Call the the
listenParam
method in theregisterOnBackend
method for each paramer as follows:
this.listenParam(paramName: string, backend: Express, getFunction: GetFunction, arrayOfArgsForGetFunction: Array<unknown>, boundParamsIndexName: string
- Add the new property to the
Application to our use case
- In the
src/types/express/index.d.ts
file:
declare global {
namespace Express {
interface Request {
...
boundParams: {
...
assignment: Assignment | undefined
};
}
}
}
- 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 objectcode
is the HTTP status code of the responsedata
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:
- If we want to publish, we check first if it is possible (if the last pipeline status is
success
). - Change the project visibility on Gitlab.
- Change the assignment published status on the Dojo database.
- 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();