Neolithic Prancing Minotaurs

    @sphericalelephant/exseq

    3.7.2 • Public • Published

    ExSeq

    NPM Version NPM Downloads Build Status Coverage Status

    About

    ExSeq uses Sequelize models to generate a REST API using the Express web framework. The following documentation always reflects the most recent release version of ExSeq.

    Installation

    $ npm install @sphericalelephant/exseq

    Features

    • CRUD API generation, including partial updates.
    • Unopinionated authorization integration via Express middlewares.
    • Supports all association types provided by Sequelize.
    • OpenAPI support

    Getting Started

    Creating Routes For Models

    const exseq = require('exseq');
    const express = require('express');
    const app = express();
    const bodyParser = require('body-parser');
    const Sequelize = require('sequelize');
    
    app.use(bodyParser.json({}));
    
    const apiData = exseq([
      {model: Car, opts: {}},
      {model: Tire, opts: {}},
    ], {
      dataMapper: Sequelize
    });
    
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    });
    
    // simple response handler
    app.use((req, res, next) => {
      if (res.__payload) { // res.__payload is created by exseq
        return res.status(res.__payload.status).send({
          result: res.__payload.result, message: res.__payload.message
        });
      }
      res.status(404).send();
    });
    // simple error handler
    app.use((err, req, res, next) => {
      if (!err.status) {
        return res.status(500).send({message: err.stack});
      }
      return res.status(err.status).send({message: err.result});
    });

    Exseq Options (opts)

    Option Description
    dataMapper The instance of the dataMapper to use. Currently only Sequelize is supported.
    rawDataResponse If set to true, ExSeq will attach the result of instance.get() to res.__payload.result, otherwise instance is attached.
    middleware
    middleware.associationMiddleware
    openapi
    idRegex The regular expression that is used to determin the correctness of an id. Uses express' route param regex. Specify the regex as a string, without enclosing ()
    whitelistedOperators Used to whitelist operators, format is: {or: true, and: true...}, by default, all operators are whitelisted. Please beware that if you provide a whitelist, all operators not included on this whitelist are forbidden by default. ExSeq will take care of translating $or to or and vice versa

    Error Objects

    Error Objects created by ExSeq can be identified using the follwing code.

    if (err.isCreatedError) {
      // this is an ExSeq generated error
    }

    ExSeq errors contain the following additional attributes.

    Atrribute Data Type Description
    success boolean For errors this is always false, used for reply message consistency
    status integer HTTP status code
    result Error The error that caused the current ExSeq error
    isCreatedError boolean A flag indicating if the current Error is an ExSeq generated Error or not

    Notes On Security

    Operator Whitelisting

    Starting from ExSeq XXX, operator whitelisting is supported. It is recommended to use the whitelist in order to mitigate (d)dos attacks, and only allow certain operators for routes can only be accessed by trusted roles. ReDos attacks are only one possible concern.

    Reporting Security Issues

    If you discover any security issues with ExSeq or one of its dependencies please don't hestiate to send an E-Mail to office[you know what to put here]sphericalelephant.com.

    Route Options (opts)

    Option Description
    route Overrides the default label for the first route segment
    authorizeWith.options.useParentForAuthorization Use the access rules of the source entity instead of the target entity, when using the source entity route to access the target entity. This flag is may only be set in the target entitiy configuration. Example: A TIRE belongsTo a CAR (or a CAR hasMany TIRES) When using /car/:id/tire/:tireId to access a tire, the user access to CAR is checked to see if the user canaccess a TIRE. This option may only be used in target entites that have either a HasOne or BelongsTo relation
    authorizeWith.options.authorizeForChildren Enables the use of the source authorization middleware for target entites. This setting must be set in the source entity. It causes all authorization request to go through the source authorization middleware. A target must only use a single source for authorization!
    authorizeWith.rules Contains authorization definition
    exposed A nested Object containing information on route exposure. Blacklist.
    createRoutes A flag indicating that routes for this model should be created, defaults to "true". This setting is relevant if OpenAPI spec must be generated but some models need to be excluded from explicitly being exposed.
    filterReferenceAttributes A flag controlling the POST /entity/ behaviour, if set to true, all reference ids (association ids) will be stripped from the reply (default)
    queryOptions.defaultLimit The default limit of returned results for GET /source, POST /source/search and POST /source/:id/target, if set to Symbol.for('NONE'), limit and offset are ignored
    queryOptions.maxLimit The maximum value that can be specified as a limit for pagination, NONE by default
    queryOptions.whitelistedOperators Used to override the global whiteListedOperators option on a per model basis, see opts.whitelistedOperators for details

    Examples

    Define a custom name for a source:

    const exseq = require('exseq');
    const Sequelize = require('sequelize');
    
    const apiData = exseq([
      {model: Person, opts: {route: 'User'}}
    ])
    
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    }, {
      dataMapper: Sequelize
    });

    Control Route exposure:

    const exseq = require('exseq');
    const Sequelize = require('sequelize');
    
    const apiData = exseq([
      {
        model: Car, opts: {
          exposed: {
            '/': {
              // forcefully excluding POST /, all
              // other routes / methods are exposed!
              post: false
            }
          }
        }
      }
    ], {
      dataMapper: Sequelize
    });

    Control Operator Whitelist:

    const exseq = require('exseq');
    const Sequelize = require('sequelize');
    
    const apiData = exseq([
      {
        model: Car, opts: {}
      }
    ], {
      // only $and, and, $or and or are allowed now
      whitelistedOperators: {or: true, $and: true}
      dataMapper: Sequelize
    });

    Authorization rules:

    const exseq = require('exseq');
    const Sequelize = require('sequelize');
    
    const isEntityOwner = (req, res, next) => {
      // handle authorization here
    };
    const isUser = (req, res, next) => {
      // handle authorization here
    }
    const deny = (req, res, next) => {
      const err = new Error();
      err.status = 401;
      return next(err);
    }
    const apiData = exseq([
      {
        model: Car, opts: {
          authorizeWith: {
            rules: {
              CREATE: isUser,
              READ: isEntityOwner,
              UPDATE: isEntityOwner,
              UPDATE_PARTIAL: isEntityOwner,
              DELETE: deny,
              SEARCH: isUser,
              // OTHER can be used to handle all cases that
              // have not been explicitly handled by any other
              // rule.
              // OTHER: deny
            }
          }
        }
      }
    ], {
      dataMapper: Sequelize
    });
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    });

    authorizeForChildren - All Tire routes are authorized by the Car rules.

    const exseq = require('exseq');
    const Sequelize = require('sequelize');
    
    const apiData = exseq([
      {
        model: Car, opts: {
          authorizeWith: {
            rules: {
              READ: isUser,
              OTHER: deny
            },
            options: {
              authorizeForChildren:  [{
                child: Tire,
                authorizeForChild: true
              }]
            }
          }
        }
      },
      {model: Tire, opts: {}}
    ], {
      dataMapper: Sequelize
    });
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    });

    useParentForAuthorization - All Car related Tire routes are authorized by the Car rules.

    const exseq = require('exseq');
    const Sequelize = require('sequelize');
    
    const apiData = exseq([
      {
        model: Car, opts: {
          authorizeWith: {
            rules: {
              READ: isUser,
              OTHER: deny
            }
          }
        }
      },
      {
        model: Tire, opts: {
          options: {
            useParentForAuthorization: true
          }
        }
      }
    ], {
      dataMapper: Sequelize
    })
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    });

    Generated Routes

    Source- and Targetmodels

    When generating routes, ExSeq differentiates beteen the source and the target model. The source model is the model whose association method is called, the target model is the one passed to the association method as a parameter:

    const SourceModel = require('./source-model');
    const TargetModel = require('./target-model');
    
    SourceModel.belongsTo(TargetModel);
    
    // or
    
    SourceModel.hasMany(TargetModel);

    Route Structure / Route Table

    The label of the first segment of the route is determined by source.name or by opts.route if specified. The label of the target model segment is determined by association.options.name.singular, meaning that it will take any aliases into account.

    Method Relation Route Permission Description
    GET N/A /source READ Obtain all instances of source
    GET N/A /source/count READ Obtains the count of all source entities.
    POST N/A /source CREATE Create a new source instance
    POST N/A /source/bulk CREATE Creates multiple new source instances
    POST N/A /source/search SEARCH Search the source table
    GET N/A /source/:id READ Obtain the specified source instance
    PUT N/A /source/:id UPDATE Replace all values of the source instance
    PATCH N/A /source/:id UPDATE_PARTIAL Replace selected values of the source instance
    DELETE N/A /source/:id DELETE Delete the specified source instance
    GET HasOne / BelongsTo /source/:id/target READ Get all target instances of source
    POST HasOne / BelongsTo /source/:id/target CREATE Create a new target instance and associate it with source
    PUT HasOne / BelongsTo /source/:id/target UPDATE Replaces all values of the target instance
    PATCH HasOne / BelongsTo /source/:id/target UPDATE_PARTIAL Replaces selected values of the target instance
    DELETE HasOne / BelongsTo /source/:id/target DELETE Remove the association
    GET HasMany / BelongsToMany /source/:id/target READ Obtains an array of all associated target instances
    GET HasMany / BelongsToMany /source/:id/target/:targetId READ Obtains a single target instance
    GET HasMany / BelongsToMany /source/:id/target/count READ Obtains the count of all target entities
    POST HasMany / BelongsToMany /source/:id/target CREATE Creates and associates a new target instance
    POST HasMany / BelongsToMany /source/:id/target/search SEARCH Search items in the target table that are related to source
    PUT HasMany / BelongsToMany /source/:id/target/:targetId UPDATE Replaces all values of the target instance
    PATCH HasMany / BelongsToMany /source/:id/target/:targetId UPDATE_PARTIAL Replaces selected values of the target instance
    DELETE HasMany / BelongsToMany /source/:id/target/:targetId DELETE Deletes the specified target instance
    POST HasMany / BelongsToMany /source/:id/target/:targetId/link ASSOCIATE Link existing source and target instances
    DELETE HasMany / BelongsToMany /source/:id/target/:targetId/unlink ASSOCIATE Unlink existing source and target instances

    Response Headers

    Route Relation Header Info
    /source/search HasMany / BelongsToMany X-Total-Count Contains the count of all results for the search query
    /source/:id/target/search HasMany / BelongsToMany X-Total-Count Contains the count of all results for the search query

    GET / POST Parameters

    Method Parameter Description Type Example
    GET a Allows attribute filtering "|" separated list of Strings /source/?a=name|birthdate|email
    POST (search) i Items per page (pagination) Integer {"i": 10, "p":2}
    POST (search) p Page (pagination), starts at 0 Integer {"i": 10, "p":2}
    POST (search) f Sort by field String {"f": "name"}
    POST (search) o Sort order Enum(ASC/DESC) {"f": "name", "o":"ASC"}
    POST (search) s Sequelize Search Query JSON {s: {value: 1}}

    Search

    ExSeq supports searching in accordance to Sequelize Querying. Please make sure to use the backwards compatible operator notation and not the symbol notation, as shown in the example below. Alternatively, you may use the string representation of the symbol.

    Backwards compatible:

    {
      "value": {
        "$like": "%foo%"
      }
    }

    Symbol string representation:

    {
      "value": {
        "like": "%foo%"
      }
    }

    Symbol (will not work due to JSON.stringify "limitations"):

    {
      "value": {
        [Op.like]: "%foo%"
      }
    }

    Note: When using the include attribtue to query data, be aware that the associated models can be fetched without explicit authorization.

    Search examples

    {
      "s": {
        "value": {
          "like": "%foo%"
        }
      }
    }
    {
      "s": {
        "value": {
          "like": "%foo%"
        },
        "include": [{
          "model": "OtherModel",
          "where": {
            "otherValue": {
              "like": "%bar%"
            }
          }
        }]
      }
    }
    {
      "s": {
        "value": {
          "like": "%foo%"
        },
        "include": [{
          "model": "OtherModel",
          "where": {
            "otherValue": {
              "like": "%bar%"
            }
          }
        }]
      },
      "f": "OtherModel.otherValue",
      "o": "ASC"
    }

    Foreign Key Authorization

    Starting from 1.3.0, ExSeq features body foreign key support and unopinionated foreign key based authorization. To enable foreign key authorization support instantiate ExSeq as shown below.

    const exseq = require('exseq');
    const express = require('express');
    const app = express();
    const bodyParser = require('body-parser');
    const Sequelize = require('sequelize');
    
    app.use(bodyParser.json({}));
    
    const apiData = exseq([
      {model: Car, opts: {}},
      {model: Tire, opts: {}},
    ], {
      dataMapper: Sequelize,
      middleware: {
        associationMiddleware: {
          fieldName: 'someField'
        }
      }
    })
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    });

    If enabled, the middleware will attach in instance of AssociationInformation to the req object, using fieldName as a key. If fieldName has not been provided, the default key associationInformation is used.

    You can now use the following code to obtain information about the relationships of a model, either by using the model or a valid foreign key.

    const authorizationMiddleware = (req, res, next) => {
      const information = req
        .associationInformation
        .getAssociationInformation(req.params.fk);
      // TODO: handle authorization here!
    };

    The information returned by getAssociationInformation will look as follows.

    For hasOne, hasMany and belongsTo:

    [{
      source: HasManySource,
      target: HasManyTarget,
      associationType: 'HasMany',
      fk: 'HasManySourceId',
      as: 'HasManyTarget'
    }]

    For belongsToMany:

    [{
      source: BelongsToManySource,
      target: BelongsToManyTarget,
      associationType: 'BelongsToMany',
      through: BelongsToManyThrough,
      sourceFk: 'BelongsToManySourceId',
      targetFk: 'BelongsToManyTargetId',
      as: 'BelongsToManyTarget'
    }]

    OpenAPI support

    Starting from 2.0.0, ExSeq supports OpenAPI 3.0.2. The OpenAPI document can be found in apiData.exspec;

    const apiData = exseq([
      ...
    ]);
    app.get('/my-api-docs', (req,res,next) => {
      res.status(200).send(apiData.exspec);
    });

    Demo Project

    The demo project is located in ./demo/, install all dependencies and run:

    cd demo
    node .
    

    You can now access the OpenAPI specification and Swagger UI at:

    http://localhost:3000/swagger-ui/

    and

    localhost:3000/swagger.json

    Update Instructions

    1.x.x to 2.x.x

    Return Type Change

    Calling exseq does not return an array of routing information any more.

    Change your 1.x.x code:

    exseq([
      ...
    ]).forEach((routing) => {
      app.use(routing.route, routing.router);
    });

    To:

    const apiData = exseq([
      ...
    ]);
    
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    });

    2.x.x to 3.x.x

    Added compulsory ExSeq setting

    Calling exseq now requires the opts.dataMapper option to be present. Currently, the only supported datamapper is Sequelize.

    Change your 2.x.x code:

    const apiData = exseq([
      ...
    ]);
    
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    });

    To:

    const Sequelize = require('sequelize');
    
    const apiData = exseq([
      ...
    ], {
      dataMapper: Sequelize
    });
    
    apiData.routingInformation.forEach((routing) => {
      app.use(routing.route, routing.router);
    });

    Route and Route Authorization Changes

    The /source/:id/target/:targetId/unlink route is now used with the DELETE method and NOT the POST method.

    The /source/:id/target/:targetId/unlink and /source/:id/target/:targetId/link route are now secured by ASSOCIATE and not by CREATE.

    Data reply

    ExSeq was inconsistent in attaching instance or instance.get() to res.__payload.result. Version 3.x.x attaches instance.get() for all routes by default. Use rawDataResponse to adjust this behaviour.

    License

    MIT

    Install

    npm i @sphericalelephant/exseq

    DownloadsWeekly Downloads

    126

    Version

    3.7.2

    License

    BSD-3-Clause

    Unpacked Size

    321 kB

    Total Files

    60

    Last publish

    Collaborators

    • siyb