Home Reference Source Repository

src/components/endpoint.js

import {isPlainObject, isFunction, isString} from '../utils/validators'
import {findBy, getBy, getId} from '../utils/data'
import {filter} from 'ramda';

function validateEndpoint(config) {
	if (!config || !isPlainObject(config)) throw new Error('Invalid configuration object. See documentation for help.');
	if (!config.path || typeof(config.path) !== 'string') throw new Error('Invalid path. Expected a string.');
}

/**
 * This component is responsible for generating the correct routes for the endpoint and it's child endpoints.
 * It also stores the current state of the endpoint.
 */
export class Endpoint {
	_parent;
	_identifierKey;
	_identifierFunc;
	_path;
	_methods;
	_nested = [];
	_state;
	_routes = [];
	_nestedRoutes = [];

	/**
	 * Creates a new instance of Endpoint.
	 * @param {EndpointConfig} config The configuration for the endpoint.
	 * @param {Endpoint} [parent] The parent endpoint of this endpoint.
	 */
	constructor(config, parent) {
		validateEndpoint(config);
		this._parent = parent;
		this._identifierKey = config.identifierKey || 'id';
		this._identifierFunc = config.identifierFunc && isFunction(config.identifierFunc) ? config.identifierFunc : getId;
		this._path = config.path;
		this._methods = config.methods;
		this._state = config.state || [];
		this.createRoutes();
		if (config.nested && Array.isArray(config.nested)) {
			this._nested = config.nested.map(endpointConfig =>
				new Endpoint({...endpointConfig, path: `${this._path}/${endpointConfig.path}`}, this));
			this._nestedRoutes = this._nested.reduce(((routes, endpoint) => routes.concat(endpoint.routes)), this._nestedRoutes);
		}
	}

	/**
	 * Generates the routes for the endpoint based on {@link EndpointConfig.methods}.
	 */
	createRoutes() {
		const customRoutes = filter(isFunction)(this._methods);
		const standardRoutes = filter(isString)(this._methods);
		customRoutes.forEach(routeFactory => this._routes.push(routeFactory('/' + this._path, this._state)));
		standardRoutes.map(method => {
			switch (method) {
				case 'GET_ALL':
					if (!Array.isArray(this._state)) throw new Error('Misconfigured route. GET_ALL state should be an array');
					this._routes.push({
						method: 'GET',
						path: `/${this._path}`,
						handler: (request, reply) => reply(this._state)
					});
					break;
				case 'GET_ONE':
					if (Array.isArray(this._state)) throw new Error('Misconfigured route. GET_ONE state should be an object');
					this._routes.push({
						method: 'GET',
						path: `/${this._path}`,
						handler: (request, reply) => reply(this._state)
					});
					break;
				case 'GET_BY_KEY':
					let handler;
					if (Array.isArray(this._state)) {
						handler = (request, reply) => {
							const id = request.params[this._identifierKey];
							const resource = getBy(this._identifierKey, id)(this._state);
							if (resource === undefined) return reply('Resource not found.').code(404);
							return reply(resource);
						}
					} else {
						handler = (request, reply) => {
							const id = request.params[this._identifierKey];
							const resource = this._state[id];
							if (resource === undefined) return reply('Resource not found.').code(404);
							return reply(resource);
						}
					}
					this._routes.push({
						method: 'GET',
						path: `/${this._path}/{id}`,
						handler
					});
					break;
				case 'POST':
					if (!Array.isArray(this._state)) throw new Error('Misconfigured route. POST state should be an array');
					this._routes.push({
						method: 'POST',
						path: `/${this._path}`,
						handler: (request, reply) => {
							const nextId = this._identifierFunc(this._state);
							const newResource = {id: nextId, ...request.payload};
							this._state.push(newResource);
							return reply('')
								.code(201)
								.header('Location', `http://localhost:3000/${this._path}/${newResource.id}/`);
						}
					});
					break;
				case 'PUT':
					if (!Array.isArray(this._state)) throw new Error('Misconfigured route. PUT state should be an array');
					this._routes.push({
						method: 'PUT',
						path: `/${this._path}/{id}`,
						handler: (request, reply) => {
							const currentResourceIndex = findBy(this._identifierKey, request.params[this._identifierKey])(this._state);
							if (currentResourceIndex === -1) throw new Error('Resource not found.').code(404);
							const currentResource = this._state[currentResourceIndex];
							this._state[currentResourceIndex] = {id: currentResource.id, ...request.payload};
							reply('');
						}
					});
					break;
				case 'DELETE':
					if (!Array.isArray(this._state)) throw new Error('Misconfigured route. DELETE state should be an array');
					this._routes.push({
						method: 'DELETE',
						path: `/${this._path}/{id}`,
						handler: (request, reply) => {
							const currentResourceIndex = findBy(this._identifierKey, request.params[this._identifierKey])(this._state);
							if (currentResourceIndex === -1) throw new Error('Resource not found.').code(404);
							const deletedResource = this._state.splice(currentResourceIndex, 1)[0];
							reply(deletedResource);
						}
					});
					break;
				default:
					throw new Error('Unknown HTTP method: ' + method);
			}
		});
	}

	/**
	 * Gets the generated routes for the endpoint.
	 * @returns {Array}
	 */
	get route() {
		return this._routes;
	}

	/**
	 * Gets the generates routes for the endpoint and all its child endpoints.
	 * @returns {Array.<T>}
	 */
	get routes() {
		return this._nestedRoutes.concat(...this._routes);
	}

	/**
	 * Gets the current configuration and state of the endpoint.
	 * @returns {EndpointConfig}
	 */
	get currentState() {
		return Object.assign({}, {
			path: this._path,
			methods: this._methods,
			state: this._state,
			nested: this._nested.map(nestedEndpoint => nestedEndpoint.currentState)
		});
	}
}