UNPKG

lux-framework

Version:

Build scalable, Node.js-powered REST APIs with almost no code.

718 lines (682 loc) 21.9 kB
// @flow import { Model } from '../database'; import { getDomain } from '../server'; import { freezeProps } from '../freezeable'; import type Serializer from '../serializer'; import type { Query } from '../database'; // eslint-disable-line max-len, no-duplicate-imports import type { Request, Response } from '../server'; // eslint-disable-line max-len, no-duplicate-imports import findOne from './utils/find-one'; import findMany from './utils/find-many'; import resolveRelationships from './utils/resolve-relationships'; import type { Controller$opts, Controller$beforeAction, Controller$afterAction } from './interfaces'; /** * ## Overview * * The Controller class is responsible for taking in requests from the outside * world and returning the appropriate response. * * Think of a Controller as a server at a restaurant. A client makes a request * to an application, that request is routed to the appropriate Controller and * then the Controller interprets the request and returns data relative to what * the client has request. * * #### Actions * * Controller actions are functions that call on a Controller in response to an * incoming HTTP request. The job of Controller actions are to return the data * that the Lux Application will respond with. * * There is no special API for Controller actions. They are simply functions * that return a value. If an action returns a Query or Promise the resolved * value will be used rather than the immediate return value of the action. * * Below you will find a table showing the different types of responses you can * get from different action return values. Keep in mind, Lux is agnostic to * whether or not the value is returned synchronously or resolved from a * Promise. * * | Return/Resolved Value | Response | * |------------------------------|--------------------------------------------| * | Array<Model> or Model | Serialized JSON String | * | Array or Object Literal | JSON String | * | String Literal | Plain Text | * | Number Literal | [HTTP Status Code](https://goo.gl/T2lMc7) | * | true | [204 No Content](https://goo.gl/GxKoqz) | * | false | [401 Unauthorized](https://goo.gl/60QqCW) | * * **Built-In Actions** * * Built-in actions refer to Controller actions that you get for free when * extending the Controller class (show, index, create, update, destroy). These * actions are highly optimized to load only the attributes and relationships * that are defined in the resolved Serializer for a Controller. * * If applicable, built-in actions support the following features described in * the [JSON API specification](http://jsonapi.org/): * * - [Sorting](http://jsonapi.org/format/#fetching-sorting) * - [Filtering](http://jsonapi.org/format/#fetching-filtering) * - [Pagination](http://jsonapi.org/format/#fetching-pagination) * - [Sparse Fieldsets](http://jsonapi.org/format/#fetching-sparse-fieldsets) * - [Including Related Resources](http://jsonapi.org/format/#fetching-includes) * * **Extending Built-In Actions** * * Considering the amount of functionality built-in actions provide, you will * rarely need to override the default behavior of a built-in action. In the * event that you do need to override a built-in action, you have the ability to * opt back into the built-in logic by calling the super class. * * Read actions such as index and show return a Query which allows us to chain * methods to the super call. In the following example we will extend the * default behavior of the index action to only match records that meet an * additional hard-coded set of conditions. We will still be able to use all of * the functionality that the built-in index action provides. * * ```javascript * // app/controllers/posts.js * import { Controller } from 'lux-framework'; * * class PostsController extends Controller { * index(request, response) { * return super.index(request, response).where({ * isPublic: true * }); * } * } * * export default PostsController; * ``` * * **Custom Actions** * * Sometimes it is necessary to add a custom action to a Controller. Lux allows * you to do so by adding an instance method to a Controller. In the following * example you will see how to add a custom action with the name `check` to a * Controller. We are implementing this action to use as a health check for the * application so we want to return the `Number` literal `204`. * * ```javascript * // app/controllers/health.js * import { Controller } from 'lux-framework'; * * class HealthController extends Controller { * async check() { * return 204; * } * } * * export default HealthController; * ``` * * The example above is nice but we can make the code a bit more concise with an * Arrow `Function`. * * ```javascript * // app/controllers/health.js * import { Controller } from 'lux-framework'; * * class HealthController extends Controller { * check = async () => 204; * } * * export default HealthController; * ``` * * Using an Arrow Function instead of a traditional method Controller can be * useful when immediately returning a value. However, there are a few downsides * to using an Arrow `Function` for a Controller action, such as not being able * to call the `super class`. This can be an issue if you are looking to extend * a built-in action. * * Another use case for a custom action could be to return a specific scope of * data from a `Model`. Let's implement * a custom `drafts` route on a `PostsController`. * * ```javascript * // app/controllers/posts.js * import { Controller } from 'lux-framework'; * import Post from 'app/models/posts'; * * class PostsController extends Controller { * drafts() { * return Post.where({ * isPublic: false * }); * } * } * * export default PostsController; * ``` * * While the example above works, we would have to implement all the custom * logic that we get for free with built-in actions. Since we aren't getting too * crazy with our custom action we can likely just call the `index` action and * chain a `.where()` to it. * * ```javascript * // app/controllers/posts.js * import { Controller } from 'lux-framework'; * * class PostsController extends Controller { * drafts(request, response) { * return this.index(request, response).where({ * isPublic: false * }); * } * } * * export default PostsController; * ``` * * Now we can sort, filter, and paginate our custom `drafts` route! * * #### Middleware * * Middleware can be a very powerful tool in many Node.js server frameworks. Lux * is no exception. Middleware can be used to execute logic before or after a * Controller action is executed. * * There are two hooks where you can execute middleware functions, * `beforeAction` and `afterAction`. Functions added to the `beforeAction` hook * will execute before the Controller action and functions added to the * `afterAction` hook will be executed after the `Controller` action. * * **Context** * * Middleware functions will be bound to the Controller they are added to upon * the start of an Application. * * Due to the lexical binding of arrow functions, if you need to use the `this` * keyword within a middleware function, declare the middleware function using * the `function` keyword and not as an arrow function. * * **Scoping Middleware** * * Middleware is scoped by Controller and includes a parent Controller's * middleware recursively until the parent Controller is the root * `ApplicationController`. This allows you to implement custom logic that can * be executed for resources, namespaces, or an entire Application. * * Let's say we want to require authentication for every route in our * Application. All we have to do is move our authentication middleware function * from the example above to the `ApplicationController`. * * ```javascript * // app/controllers/application.js * import { Controller } from 'lux-framework'; * * class ApplicationController extends Controller { * beforeAction = [ * async function authenticate(request) { * if (!request.currentUser) { * // 401 Unauthorized * return false; * } * } * ]; * } * * export default ApplicationController; * ``` * * **Execuation Order** * * Understanding the execution order of middleware functions and a `Controller` * action is essential to productivity with Lux. Depending on what you use case * is, you may want your function to execute at different times in the * `request` / `response` cycle. * * 1. Parent `Controller` `beforeAction` hooks * 2. `Controller` `beforeAction` hooks * 3. `Controller` Action * 4. `Controller` `afterAction` hooks * 5. Parent `Controller` `afterAction` hooks * * **Modules** * * It is considered a best practice to define your middleware functions in * separate file and export them for use throughout an Application. Typically * this is done within an `app/middleware` directory. * * ```javascript * // app/middleware/authenticate.js * export default async function authenticate(request) { * if (!request.currentUser) { * // 401 Unauthorized * return false; * } * } * ``` * * This keeps the Controller code clean, easier to read, and easier to modify. * * ```javascript * // app/controllers/application.js * import { Controller } from 'lux-framework'; * import authenticate from 'app/middleware/authenticate'; * * class ApplicationController extends Controller { * beforeAction = [ * authenticate * ]; * } * * export default ApplicationController; * ``` * * @class Controller * @public */ class Controller { /** * An array of custom query parameter keys that are allowed to reach a * Controller instance from an incoming `HTTP` request. * * For security reasons, query parameters passed to Controller actions from an * incoming request other than sort, filter, and page must have their key * whitelisted. * * ```javascript * class UsersController extends Controller { * // Allow the following custom query parameters to be used for this * // Controller's actions. * query = [ * 'cache' * ]; * } * ``` * * @property query * @type {Array} * @default [] * @public */ query: Array<string> = []; /** * An array of sort query parameter values that are allowed to reach a * Controller instance from an incoming `HTTP` request. * * If you do not override this property all of the attributes specified in the * Serializer that represents a Controller's resource. If the Serializer * cannot be resolved, this property will default to an empty array. * * @property sort * @type {Array} * @default [] * @public */ sort: Array<string> = []; /** * An array of filter query parameter keys that are allowed to reach a * Controller instance from an incoming `HTTP` request. * * If you do not override this property all of the attributes specified in the * Serializer that represents a Controller's resource. If the Serializer * cannot be resolved, this property will default to an empty array. * * @property filter * @type {Array} * @default [] * @public */ filter: Array<string> = []; /** * An array of parameter keys that are allowed to reach a Controller instance * from an incoming `POST` or `PATCH` request body. * * If you do not override this property all of the attributes specified in the * Serializer that represents a Controller's resource. If the Serializer * cannot be resolved, this property will default to an empty array. * * @property params * @type {Array} * @default [] * @public */ params: Array<string> = []; /** * Functions to execute on each request handled by a `Controller` before the * `Controller` action is executed. * * Functions added to the `beforeAction` hook behave similarly to `Controller` * actions, however, they are expected to return `undefined`. If a middleware * function returns a value other than `undefined` the `request` / `response` * cycle will end before remaining middleware and/or Controller actions are * executed. This makes the `beforeAction` hook a very powerful tool for * dealing with many common tasks, such as authentication. * * Functions called from the `beforeAction` hook will have `request` and * `response` objects passed as arguments. * * **Example:** * * ```javascript * import { Controller } from 'lux-framework'; * * const UNSAFE_METHODS = /(?:POST|PATCH|DELETE)/i; * * function isAdmin(user) { * if (user) { * return user.isAdmin; * } * * return false; * } * * async function authentication(request) { * const { method, currentUser } = request; * const isUnsafe = UNSAFE_METHODS.test(method); * * if (isUnsafe && !isAdmin(currentUser)) { * return false; // 401 Unauthorized if the current user is not an admin. * } * } * * class PostsController extends Controller { * beforeAction = [ * authentication * ]; * } * * export default PostsController; * ``` * * @property beforeAction * @type {Array} * @default [] * @public */ beforeAction: Array<Controller$beforeAction> = []; /** * Functions to execute on each request handled by a `Controller` after the * `Controller` action is executed. * * Functions called from the `afterAction` hook will have `request` and * `response` objects passed as arguments as well as a third `payload` * argument. The `payload` argument is a reference to the resolved data of * the Controller action that was called within the current `request` / * `response` cycle. You need to explicitly return this `payload` in order for * the afterAction to resolve with it's data. If you return a modified value * from a function added to the `afterAction` hook, that value will be used * instead of the resolved data from the preceding Controller action. * Subsequent hooks called from an `afterAction` hook will will use the value * returned or resolved from the preceding hook. This makes `afterAction` a * great place to modify the data you are sending back to the client. * * **Example:** * * ```javascript * import { Controller } from 'lux-framework'; * * async function addCopyright(request, response, payload) { * const { action } = request; * * if (payload && action !== preflight) { * return { * ...payload, * meta: { * copyright: '2016 (c) Postlight' * } * }; * } * * return payload; * } * * class ApplicationController extends Controller { * afterAction = [ * addCopyright * ]; * } * * export default ApplicationController; * ``` * * @property afterAction * @type {Array} * @default [] * @public */ afterAction: Array<Controller$afterAction> = []; /** * The default amount of items to include per each response of the index * action if a `?page[size]` query parameter is not specified. * * @property defaultPerPage * @type {Number} * @default 25 * @public */ defaultPerPage: number = 25; /** * The resolved Model for a Controller instance. * * @property model * @type {Model} * @private */ model: Class<Model>; /** * A reference to the root Controller for the namespace that a Controller * instance is a member of. * * @property parent * @type {?Controller} * @private */ parent: ?Controller; /** * The namespace that a Controller instance is a member of. * * @property namespace * @type {String} * @private */ namespace: string; /** * The resolved Serializer for a Controller instance. * * @property serializer * @type {Serializer} * @private */ serializer: Serializer<*>; /** * A Map instance containing a reference to all the Controller within an * Application instance. * * @property controllers * @type {Map} * @private */ controllers: Map<string, Controller>; /** * A boolean value representing whether or not a Controller instance has a * Model. * * @property hasModel * @type {Boolean} * @private */ hasModel: boolean; /** * A boolean value representing whether or not a Controller instance is within * a namespace. * * @property hasNamespace * @type {Boolean} * @private */ hasNamespace: boolean; /** * A boolean value representing whether or not a Controller instance has a * Serializer. * * @property hasSerializer * @type {Boolean} * @private */ hasSerializer: boolean; constructor({ model, namespace, serializer }: Controller$opts) { Object.assign(this, { model, namespace, serializer, hasModel: Boolean(model), hasNamespace: Boolean(namespace), hasSerializer: Boolean(serializer) }); freezeProps(this, true, 'model', 'namespace', 'serializer' ); freezeProps(this, false, 'hasModel', 'hasNamespace', 'hasSerializer' ); } /** * This method supports filtering, sorting, pagination, including * relationships, and sparse fieldsets via query parameters. For more * information, see the [fetching resources](https://goo.gl/q7FVgZ) section of * the JSON API specification. * * @method index * @param {Request} request - The request object. * @param {Response} response - The response object. * @return {Promise} Resolves with an array of Model instances. * @public */ index(req: Request): Query<Array<Model>> { return findMany(this.model, req); } /** * This method supports including relationships, and sparse fieldsets via * query parameters. For more information, see the [fetching resources]( * https://goo.gl/q7FVgZ) section of the JSON API specification. * * @method show * @param {Request} request - The request object. * @param {Response} response - The response object. * @return {Promise} Resolves with a Model instance with the id equal to the * id url parameter. * @public */ show(req: Request): Query<Model> { return findOne(this.model, req); } /** * Create and return a single Model instance that the Controller instance * represents. For more information, see the [creating resources]( * https://goo.gl/4Obc9t) section of the JSON API specification. * * @method create * @param {Request} request - The request object. * @param {Response} response - The response object. * @return {Promise} Resolves with the newly created Model instance. * @public */ async create(req: Request, res: Response): Promise<Model> { const { model } = this; const { url: { pathname }, params: { data: { attributes, relationships } } } = req; const record = await model.create({ ...attributes, ...resolveRelationships(model, relationships) }); res.setHeader( 'Location', `${getDomain(req) + pathname}/${record.getPrimaryKey()}` ); Reflect.set(res, 'statusCode', 201); return record.unwrap(); } /** * Update and return a single Model instance that the Controller instance * represents. For more information, see the [updating resources]( * https://goo.gl/o2ZdOR)section of the JSON API specification. * * @method update * @param {Request} request - The request object. * @param {Response} response - The response object. * @return {Promise} Resolves with the updated Model if changes occur. * Resolves with the number `204` if no changes occur. * @public */ update(req: Request): Promise<number | Model> { const { model } = this; return findOne(model, req) .then(record => { const { params: { data: { attributes, relationships } } } = req; return record.update({ ...attributes, ...resolveRelationships(model, relationships) }); }) .then(record => { if (record.didPersist) { return record.unwrap(); } return 204; }); } /** * Destroy a single Model instance that the Controller instance represents. * For more information, see the [deleting resources](https://goo.gl/nUZn8t) * section of the JSON API specification. * * @method destroy * @param {Request} request - The request object. * @param {Response} response - The response object. * @return {Promise} Resolves with the number `204`. * @public */ destroy(req: Request): Promise<number> { return findOne(this.model, req) .then(record => record.destroy()) .then(() => 204); } /** * Respond to HEAD or OPTIONS requests. * * @method preflight * @param {Request} request - The request object. * @param {Response} response - The response object. * @return {Promise} Resolves with the number `204`. * @public */ preflight(): Promise<number> { return Promise.resolve(204); } } export default Controller; export { BUILT_IN_ACTIONS } from './constants'; export type { Controller$opts, Controller$builtIn, Controller$beforeAction, Controller$afterAction, } from './interfaces';