Skip to content
This repository has been archived by the owner on Mar 27, 2024. It is now read-only.

Dev documentation

Aurélien Labate edited this page May 30, 2017 · 7 revisions

This file document some parts of this project that are not easy to understand only from comments. Please keep it up to date if you modify the behaviour of something.

Snippets

Here you can find some examples of code that we use a lot in this project. Praise the lord of copy-past.

Get informations about request and user

# Get authenticated user or undefined
req.user

# Get authenticated team or undefined
req.team

# Access data (from body, query in uri or param in uri)
req.data

# Test if a team has a permission
team.can('permission')

Create OK response

# Send a 200 response with the given data.
res.ok();
res.ok({data: 'data', data2: 'data2'});

Create Error response

To send an error to the client, you just have to throw a special error. You can find erros in /lib/Error.js

  • NotFoundError will send an error 400
  • ForbiddenError will send an error 403
  • BadRequestError will send an error 400
  • ExpectedError: Custom error, see after
  • sequelize validation error: it will send a 400 with _error.validation containing an array of validation errors.
  • Other Error thrown: it will send an error 500 with "Unexpected error" message. For security reason, the client will not be able to get more informations, but their will be more informations in server log.

Example:

const {NotFoundError, ForbiddenError} = require('../../lib/Errors');
throw new NotFoundError('The element you want cannot be found');

To generate custom error without creating a new exception, there is Expected error that you can generate with a Status code, StatusShortText, and full english technical message.

throw new ExpectedError(401, 'IPNotFound', 'There is no User associated with this IP');

The Flux object

Having a global object is generaly considered as a bad practice for obvious reasons. But it's also annoying to have to include in every files things that are used everywhere.

So for this reason, we choosed to create a Flux object, that is not global, but you can include it from everywhere and it will always be the same object (singleton). So if you want to use the Flux object, you just have to do :

import Flux from './Flux';

Content of the object

  • Flux.config : read configuration files (merged into one object according to the configuration part of this docummentation)
  • Flux.configReload() : Method to force Flux to reload configuration from files
  • Flux.rootdir : Contains the absolute path to the root directory of the projet, without / at the end.
  • Flux.log.error : Log in console in red, and append to production and development log
  • Flux.log.warn : Log in console in orange, and append to production and development log
  • Flux.log.info : Log in console in blue, and append to development log
  • Flux.log.debug : Log in console in green, and append to development log
  • Flux.env : Contains current environnement name (production, development, etc)
  • Flux.controllers : Loaded controllers
  • Flux.io : Socket.io object
  • Flux.express : Express object
  • Flux.server : HTTP server object
  • Flux.sequelize : Sequelize object
  • Flux.[Model] : You can access every loaded model. Example:
Flux.User.findAll()

As you can see, all methods and attributes of the Flux object except models begin with a lowercase letter. This is necessary to avoid collisions between models and other properties. Please keep it that way.

Configuration

All configuration of the project is in the config/ directory. All files are loaded and exped into Flux.config. Files finishing by .NODE_ENV.js will replace values from original config file. and files finishing by .local.js will replace values from both precedent files. However .local.js files are ignored by .git, so you can customize configuration for your local use.

Example: If we are in production (NODE_ENV=production)

# `config/test.js`
module.exports = {
    test1: 'test.js',
    test2: 'test.js',
    test3: 'test.js',
}

# `config/test.production.js`
module.exports = {
    test2: 'test.production.js',
    test3: 'test.production.js',
}

# `config/test.local.js`
module.exports = {
    test3: 'test.local.js',
}

Then, because of priorities the value of Flux.config.test will be

{
    test1: 'test.js',
    test2: 'test.production.js',
    test3: 'test.local.js',
}

Note also that every file that doesn't follow the conventions will throw a warning on server start, except if the file ends with .dist.

Express, routes and middlewares

To be able to process HTTP websocket request exactly the same way, Flux2-server rely internally on an Express server. As Express is only a HTTP server, request received via Socket.io are injected into Express server as if they were simple http requests.

Routes are defined by the config file config/routes.js which is an object that associate

'METHOD /path/:param': { action: 'CONTROLLER.ACTION', middlewares: ['MIDDLEWARE1', 'MIDDLEWARE2'], file: 'NAME OF THE MULTIPART FILE FIELD' }
# note: `file` attribute is optionnal. Currently, it only allow the upload of a single
# file `multer`, however if you need more one day, you can edit `app.js` to handle
# array or objects associated with this `file` attribute.

# Example
'put /alert/:id/users': { action: 'AlertController.updateAssignedUsers', middlewares: ['auth', 'requireAuth'] },

See Express documentation get more informations about path format.

Middlewares

  • auth : This middleware try to authenticate user with bearer jwt or socket id. On success, req.user and req.team will be set. On failure, no error will happend, use requireAuth after auth, if you want to restrict access to logged in users.
  • requireAuth : This middleware will throw 401 Unauthorized error if user is not authenticated before with auth middleware.
  • reqLogger: Debug middleware that will write to console every received requests
  • resLogger: Debug middleware that will write to console every sent responses

Core middlewares : Thoses middlewares are automatically added, and should not be removed.

  • errorHandler : This will handle any exception or error (except 404) and throw an error 500
  • notFoundHandler : This will be executed when the route doesn't lead to an action and will throw a 404.
  • extendRes : Extend the res object with helpers (ok, error, error500), that help you to format your response.
  • dataParser: This middleware parse and merge all data sources (query, body, params) and expose an unique req.data object.

Controller's auto CRUD and models groups

Generated actions

Each controllers associated with a model that inherit Controller.js will have thoses actions available :

  • find(req,res) : Return a list of associated items with a parameter filters which have to be compatible with sequelize where condition.
  • create(req,res) : Create an item. Every parameter that has the name of an attribut will be set to this new item
  • update(req,res) : Update the item with the given id. Every parameter that has the name of an attribut will be updated in this item.
  • destroy(req,res) : Destroy the item with the given id
  • subscribe(req,res) : Subscribe to all events on this model
  • unsubscribe(req,res) : Unsubscribe from all events on this model

To make thoses actions available in the api, you have to create routes for them in config/routes.js.

Permissions for thoses actions

By default, any user with model/admin, will have access to all generated actions, and model/read, will only be able to read. But if you want to tweak permissions, you will have to overload some methods in the model.

Note: even if thoses methods are in the model, this permission system only apply to generated actions in the controller. Other methods can still read or modify model item even if user doen't have the permission to do it.

The idea behind this permission system, is that for each action type (read, created, update, delete), user is associated to groups, and each item is also associated with group. If one of thoses groups match, then the user can do the action on this item.

Permissions example

We will take for exemple the UserController configuration for read. We want

  • user/admin and user/read can read everything
  • user/team can read only member from his team
  • Other users can only read their own user entry
// For each item, we define groups that can read it
Model.prototype.getReadGroups = function() {
    // Every user which is in one of thoses groups will be able to read it
    return [
        'all',                 # Used for user that can read everything
        'id:' + this.id,       # Used for user that can only read one entry
        'team:' + this.teamId, # Used for user that can only read entry from one team
    ];
};

// Then we define groups for the given user
Model.getUserReadGroups = (team, user) => {
    let groups = [];

    // Admin can read eveything so we put them in the `all` group
    if(team.can(Model.name + '/read') || team.can(Model.name + '/admin')) {
        groups.push('all');
    }

    // user can always read his own user entry, so we let him read only its own id
    groups.push('id:' + user.id);

    // If you can only see member of your team
    if(team.can(Model.name + '/team')) {
        groups.push('team:' + team.id);
    }

    return groups;
};

We known that it's not the easyest system to understand, but it's extremly powerfull because it work for simple CRUD, but it also work for socket publish/subscribe system that will publish item only to allowed users.

This exemple is for read action, but you can do the same for other actions:

Model.prototype.getReadGroups
Model.prototype.getUpdateGroups
Model.prototype.getCreateGroups
Model.prototype.getDestroyGroups

Model.getUserReadGroups
Model.getUserUpdateGroups
Model.getUserCreateGroups
Model.getUserDestroyGroups

For optimisation reason, there is a another overloadable Model method. This method create Sequelize filters that will be happend to requested filter for find() request. This method exist only for the read action.

Model.getReadFilters = function(team, user) {
    let filters = [];

    // Get groups associated with the user
    let groups = this.getUserReadGroups(team, user);

    // And generate sequelize filters from thoses groups
    for (let group of groups) {
        let split = group.split(':');
        // Can read all
        if(group == 'all') {
            return [{true: true}];
        }
        // Can read only one id
        else if(split.length == 2 && split[0] == 'id') {
            filters.push({'id': split[1]});
        }
        // Can read only one team
        else if(split.length == 2 && split[0] == 'team') {
            filters.push({'teamId': split[1]});
        }
    }
    return filters;
};

If you don't understand something, just try to look at others controllers and models, you should be able to find any use case you want.

Websocket request server implementation

To execute websocket request exactly like HTTP requests, we have to simulate an HTTP request. Their is two options to do that:

  • Forge response and request object and inject them into Express server
  • Create a new request and send it to localhost so Express take it as a legit request.

We choosed to use the first solution because it should be faster. However, it's possible that this code break in the future because we play with not fully docummented code, nearly internal code. Either express or nodejs could change a bit the API and break our code.

We choosed to keep this solution because the risky code is small and not so hard to understand. So if for future upgrades, websocket requests are not executed anymore, you can take a look at lib/FluxWebSocket:injectHttpRequest().