Server Development¶
The freenalytics/freenalytics repository is a monorepo containing both the web dashboard and the server.
Any changes made to the server should be made in this repository.
Requirements¶
In order to develop for the server you will need:
Setting Up¶
First, clone the repository:
git clone https://github.com/freenalytics/freenalytics
Head over to the server
folder:
cd server
And then, install the dependencies:
npm install
Setting Up the Development Environment¶
The server requires MongoDB and Redis instances in order to run. The server
folder includes a dev
folder with a docker-compose.yml
file inside with a MongoDB and Redis service.
Head over to this folder and run:
docker-compose up
This will start both a MongoDB server at port 27017 and a Redis server at port 6379.
Environment Variables¶
Inside the server
folder create a .env
file with the following content:
MONGODB_URI=mongodb://root:password@localhost:27017/freenalytics?authSource=admin
REDIS_URI=redis://localhost:6379
JWT_SECRET=my_super_secret
JWT_TOKEN_DURATION=604800
REGISTRATION_OPEN=true
Note
Nothing in this folder needs to be updated. It already sets the MongoDB and Redis URIs to the appropriate values, taking into account that you have started these services with the docker-compose.yml
file that was previously mentioned.
Starting the Server¶
Development Mode¶
There are two ways to start the server in development mode:
- If you want to run the server without automatically restarting on file save you can use:
npm run dev
- If you wish to use watch mode (server restarts on file save), use:
npm run dev:watch
In both cases the server will start on port 4000.
Production Mode¶
In case you wish to start the server in production mode:
You need to first build the server:
npm run build
And then start it:
npm run start
Manually Testing¶
In order to check functionality while developing the server, you may need an HTTP client such as Insomnia or Postman.
The URL to test will be: http://localhost:4000/api
. Check the src/routes
folder for the relevant routers that you wish to test.
Considerations¶
Linting¶
This project uses ESLint rules to maintain a consistent code style. You can run the linter to check for any linting errors with:
npm run lint
And fix any fixable errors automatically with:
npm run lint:fix
Unit Testing¶
This project contains unit tests for every component. You can run the unit tests with:
npm run test
Or, if you want to run the test suites in watch mode (will re-run relevant tests on file save), you can use:
npm run test:watch
Developing Routes¶
Route development will usually go like this:
- You create a service function to query or mutate the Mongo database.
- You create a controller function that will handle the incoming request.
- You create a route function that will map the controller to a specific route and HTTP method.
Each of these entities are independent to ensure modularity and ease of testing. Each of these functions should be unit tested. Routers do not need to be tested because the logic inside of them is already tested in the unit test for the controller.
- Services that talk to the database are located inside the
src/services
folder. - Controllers that handle requests are located inside the
src/controllers
folder. - Routers that map the request handlers to the HTTP route and method are located in
src/routes
folder.
Example¶
Let's see the example of the data upload route which is the most complex one currently in the application. This route includes schema fetching from the Redis cache, body validation against the stored schema, and a Mongo database insertion.
Model¶
Inside the src/models
folder you will find mongoose models that will be used to define the shape of the data stored in MongoDB. These models are used to interact with the database.
// src/models/data.ts
import { Schema, model } from 'mongoose';
export interface DataModel {
payload: object
domain: string
createdAt: Date
}
const dataSchema = new Schema<DataModel>({
payload: {
type: Schema.Types.Mixed,
required: true
},
domain: {
type: String,
required: true
}
}, {
timestamps: {
createdAt: 'createdAt',
updatedAt: false
}
});
export default model<DataModel>('Data', dataSchema);
In here, the Data
model defines the shape that the data entry will have and exports the model.
Service¶
Next, we create a service function that will get the application schema from the Redis cache or from the Mongo database in case it does not exist in the database. This function will also set the schema inside the cache for a faster fetching the next time the schema is needed.
// src/services/dataService.ts
export const getApplicationSchema = async (domain: string): Promise<object> => {
const key = `${domain}:schema`;
const cacheHit = await redisClient.exists(key);
if (cacheHit) {
const cachedSchema = await redisClient.get(key);
return JSON.parse(cachedSchema!);
}
const application = await Application.findOne({ domain }).exec();
if (!application) {
throw new ResourceNotFoundError(`Application ${domain} was not found.`);
}
await redisClient.set(key, JSON.stringify(application.template.schema));
return application.template.schema;
};
Next, we create a service function that will insert the data payload inside the Mongo database.
// src/services/dataService.ts
export const createDataForApplication = async (domain: string, validData: object): Promise<DataModel> => {
const data = { domain, payload: validData } as DataModel;
await new Data(data).save();
return data;
};
Controller¶
We can now create a controller function that will handle all the logic of the route handler.
// src/controllers/dataController.ts
export const create = async (req: Request, res: Response, next: NextFunction) => {
const { domain } = req.params;
try {
const schema = await getApplicationSchema(domain);
validateDataWithTemplate(req.body, schema);
const data = await createDataForApplication(domain, req.body);
const response = new ResponseBuilder()
.withStatusCode(HttpStatus.CREATED)
.withData(data);
res.status(response.statusCode).send(response.build());
} catch (error) {
next(error);
}
};
All the logic is here. We fetch the schema for the current application, we validate that the request body conforms to the fetched schema, then we insert the data into the Mongo database and finally we respond to the requesting party with the data that was inserted.
Route¶
Finally, we register this controller in the appropriate router with the corresponding route and method.
// src/routes/applicationRouter.ts
router.route('/:domain/data')
.get(verifyUser, dataController.get)
.post(jsonBodyRequired, dataController.create) // This is the line that has been added in this case.
.all(onlySupportedMethods('GET', 'POST'));
And that's it. Since this route was defined inside the applicationRouter.ts
file, the endpoint for this route will be:
POST http://localhost:4000/api/applications/:domain/data
Creating Documentation¶
The server has a documentation site which includes information of all the routes exposed in the API.
Inside the server
folder there is a documentation
folder, which includes a script that generates the OpenAPI specification file that will contain all the data to render the documentation site.
In order to create a new entry, you should:
- Inside the
documentation/schemas
folder, open the file corresponding to the router where you have created your route handler. - Create a new
RequestSchema
(if your route requires a request body) and aResponseSchema
that contains the structure of the data returned by the route handler. These schemas are joi schemas and in this particular use case they only serve as a way to represent the requests and responses, and are not used for any sort of validation. - Inside the
documentation/routes
folder, open the folder corresponding to the route of your created route handler and create a new file with the same name as the service that your route handler executes. The idea is that these files should be descriptive of what route exactly is it that they document. - Inside this newly created file, export a RouteData object with all the information corresponding to your route.
- Inside the
documentation/documentation.ts
file, import your newly created route data file and include it inside the exported object in thepaths
object inside the corresponding object (depending on the name of the folder that you created your route data file in). These entries are in the same order that will be displayed in the documentation site, so keep that in mind.
RouteData¶
Your route data file should export an object of type RouteData
which includes the following fields:
path
: The path of the route handler. (Requiredstring
)method
: The method of the route handler. (Required One of:get
,put
,post
,delete
,patch
)summary
: A little summary of what the route handler does. (Requiredstring
)description
: A more detailed description of what the route handler does. (Requiredstring
)throws
: An array of the instances of the potentialHttpError
errors that your route handler may throw. (OptionalHttpError[]
)success
: An object that describes the information of a successful response. (Required)success.code
: The HTTP response code that your route handler responds with on successful response. (Requirednumber
)success.schema
: The name of theResponseSchema
that your route handler responds with (if any). (Optionalstring
)success.isArray
: Whether the response object is an array ofResponseSchema
objects or not. (Optionalboolean
- Default:false
)success.binaryType
: The MIME-type of the response in case the response is a binary blob. (Optionalstring
)
pathParams
: An array of objects that describe each path parameter inside the route. (Optional)pathParams[].name
: The name of the path parameter. (Requiredstring
)pathParams[].description
: A description of what the path parameter represents. (Requiredstring
)pathParams[].type
: The type of the path parameter. (Required One of:string
,number
)
queryParams
: An array of objects that describe each query parameter that can be used in the route. (Optional)queryParams[].name
: The name of the query parameter. (Requiredstring
)queryParams[].description
: A description of what the query parameter represents. (Requiredstring
)queryParams[].required
: Whether the query parameter is required or not. (Requiredboolean
)queryParams[].type
: The type of the query parameter. (Required One of:string
,boolean
,number
)queryParams[].isArray
: Whether the query parameter is an array or not. (Requiredboolean
)
bodySchema
: The name of theRequestSchema
that your route handler needs as a JSON body (if any). (Optionalstring
)tokenRequired
: Whether the route handler requires a bearer token to be specified in theAuthorization
header. (Optionalboolean
)
Note
If your route has tokenRequired
set to true, you need to add a UnauthorizedError
instance inside throws
.
Example Documentation¶
Here we'll use the same example as used above, for the route that handles data upload.
Schema¶
The relevant schemas are as following:
// documentation/schemas/application.ts
export const ApplicationDataRequestSchema = Joi.object({
example: Joi.string().required()
});
export const ApplicationDataResponseSchema = Joi.object({
domain: Joi.string().required(),
payload: ApplicationDataRequestSchema
});
In this case, the example
is really only an example. Since data can come in any shape or form, an example body was necessary.
RouteData File¶
The file with the information of this route will then be:
// documentation/routes/applications/createApplicationData.ts
import HttpStatus from 'http-status-codes';
import { RouteData } from '../../types';
import { UnauthorizedRequestError, SchemaValidationError, ResourceNotFoundError } from '../../../src/errors/http';
const data: RouteData = {
path: '/applications/:domain/data',
method: 'post',
summary: 'Create a new data entry for an application for the requesting user.',
description: `Create a new data entry for an application for the requesting user.
The shape of the request body will depend on the schema that was used to create the application.`,
throws: [
new UnauthorizedRequestError(),
new SchemaValidationError(''),
new ResourceNotFoundError('')
],
success: {
code: HttpStatus.CREATED,
schema: 'ApplicationDataResponseSchema'
},
pathParams: [
{
name: 'domain',
description: 'The domain of the application to upload the data to.',
type: 'string'
}
],
bodySchema: 'ApplicationDataRequestSchema',
tokenRequired: true
};
export default data;
Including the RouteData File¶
Finally, we need to include this route data file into the documentation/documentation.ts
file. Just import this file and add it into the exported object.
// documentation/documentation.ts
import createApplicationData from './routes/applications/createApplicationData';
const documentationData = {
metadata,
info,
paths: {
Application: {
getAllApplicationsForUser,
createApplicationForUser,
getApplicationByDomain,
updateApplicationByDomain,
deleteApplicationByDomain,
createApplicationData,
getApplicationData,
getApplicationDataAsCsv
}
}
};
export default documentationData;
The rest of the file was omitted as it is not relevant. Here you can see that createApplicationData
is exported near the end of the paths.Application
object.