UNPKG

express-idempotency

Version:

Add idempotency to your express route, effortlessly, the way you want it.

273 lines (272 loc) 12.1 kB
"use strict"; // Copyright (c) Ville de Montreal. All rights reserved. // Licensed under the MIT license. // See LICENSE file in the project root for full license information. var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.IdempotencyService = void 0; const autobind_decorator_1 = require("autobind-decorator"); const defaultIntentValidator_1 = require("./../defaults/defaultIntentValidator"); const HttpStatus = __importStar(require("http-status-codes")); const inMemoryDataAdapter_1 = require("./../defaults/inMemoryDataAdapter"); const successfulResponseValidator_1 = require("./../defaults/successfulResponseValidator"); // Default values const IDEMPOTENCY_KEY_HEADER = 'idempotency-key'; const HIT_HEADER = 'x-hit'; const HIT_VALUE = 'true'; /** * This class represent the idempotency service. * It contains all the logic. */ let IdempotencyService = class IdempotencyService { /** * Constructor, used to initialize default values if options are not provided. * @param options Options provided */ constructor(options = {}) { var _a, _b, _c, _d; // Default values or provided values const idempotencyKeyHeader = (_a = options.idempotencyKeyHeader) !== null && _a !== void 0 ? _a : IDEMPOTENCY_KEY_HEADER; const dataAdapter = (_b = options.dataAdapter) !== null && _b !== void 0 ? _b : new inMemoryDataAdapter_1.InMemoryDataAdapter(); const responseValidator = (_c = options.responseValidator) !== null && _c !== void 0 ? _c : new successfulResponseValidator_1.SuccessfulResponseValidator(); const intentValidator = (_d = options.intentValidator) !== null && _d !== void 0 ? _d : new defaultIntentValidator_1.DefaultIntentValidator(); // Ensure that every propery has a value. this._options = { idempotencyKeyHeader, dataAdapter, responseValidator, intentValidator, }; } /** * Provide middleware function to enable idempotency. * @param req Express request * @param res Express response * @param next Express next function */ provideMiddlewareFunction(req, res, next) { return __awaiter(this, void 0, void 0, function* () { // Get the idempotency key to determine if there is something to process const idempotencyKey = this.extractIdempotencyKeyFromReq(req); if (idempotencyKey) { res.setHeader(this._options.idempotencyKeyHeader, idempotencyKey); // If there is already a resource associated to this idempotency key, // there will be 2 scenarios: the previous request is still in progress or there is // a response available. let resource = yield this._options.dataAdapter.findByIdempotencyKey(idempotencyKey); if (resource) { // Indicate idempotency exists req.headers[HIT_HEADER] = HIT_VALUE; // Validate the intent before going any further. This is to avoid misuse of the // idempotency key function. This could also lead to security vulnerability // because someone could send random key to get response. if (this._options.intentValidator.isValidIntent(req, resource.request)) { const availableResponse = resource.response; if (availableResponse) { // Set original headers for (const header of Object.keys(availableResponse.headers)) { res.setHeader(header, availableResponse.headers[header]); } // Send saved response if available res.status(availableResponse.statusCode).send(availableResponse.body); next(); } else { // Previous request in progress const conflictError = new Error('A previous request is still in progress for this key.'); res.status(HttpStatus.CONFLICT); next(conflictError); } } else { // Invalid intent. Client must correct his request. const invalidIntentError = new Error('Misuse of the idempotency key. Please check your request.'); res.status(HttpStatus.EXPECTATION_FAILED); next(invalidIntentError); } } else { // No resource, so initiate the idempotency process resource = { idempotencyKey, request: this.convertToIdempotencyRequest(req), }; yield this._options.dataAdapter.create(resource); this.setupHooks(res, resource); next(); } } else { next(); } }); } /** * Verify if the request is idempotent and so, nothing should be done * in term of processing. * @param req Request to validate hit */ isHit(req) { return req.get(HIT_HEADER) === HIT_VALUE; } /** * Indicate that an error occurs during targeted process and idempotency must not occurs. * @param req Request to report in error */ reportError(req) { return __awaiter(this, void 0, void 0, function* () { const idempotencyKey = this.extractIdempotencyKeyFromReq(req); yield this._options.dataAdapter.delete(idempotencyKey); }); } /** * Convert a request into a idempotency request which keeps only minimal representation. * @param req */ convertToIdempotencyRequest(req) { return { body: req.body, headers: req.headers, method: req.method, query: req.query, url: req.url, }; } /** * Extract idempotency key from request. * @param req */ extractIdempotencyKeyFromReq(req) { return req.get(this._options.idempotencyKeyHeader); } /** * Override function, which is the correct way. But Typescript won't allow it because there is multiple overloads. * @param res * @param resource */ setupHooks(res, resource) { // Wait for all promise to come back. To ensure performance, // fire and forget. const idempotencyKey = resource.idempotencyKey; Promise.all([ this.writeHeadHook(res), this.sendHook(res), ]) .then(([[statusCode], body]) => __awaiter(this, void 0, void 0, function* () { // Receive everything required to assemble a idempotency response. // logger.info(headers); const response = this.buildIdempotencyResponse(res, statusCode, body); try { // Validate against conditions to determine if valid response if (this._options.responseValidator.isValidForPersistence(response)) { const newResource = Object.assign(Object.assign({}, resource), { response }); yield this._options.dataAdapter.update(newResource); } else { yield this._options.dataAdapter.delete(idempotencyKey); } } catch (err) { console.log('Error while validating response for persistence.'); throw err; } })) .catch(() => __awaiter(this, void 0, void 0, function* () { try { console.log('Something went wrong, try to remove idempotency...'); yield this._options.dataAdapter.delete(idempotencyKey); } catch (err) { console.log('Error while removing idempotency key during failing hook.'); } })); } /** * Hook into writeHead function of response to receive the status code * and the headers. * @param res */ writeHeadHook(res) { return new Promise((resolve) => { const defaultWriteHead = res.writeHead.bind(res); // @ts-ignore res.writeHead = (statusCode, reasonPhrase, headers) => { resolve([statusCode, headers]); defaultWriteHead(statusCode, reasonPhrase, headers); }; }); } /** * Hook into send function of the response to receive the body. * @param res */ sendHook(res) { return new Promise((resolve) => { const defaultSend = res.send.bind(res); // @ts-ignore res.send = (body) => { resolve(body); defaultSend(body); }; }); } /** * Build idempotency response from hook responses and the response itself. * @param res * @param statusCode * @param body */ buildIdempotencyResponse(res, statusCode, body) { const headerWhitelist = ['content-type']; const preliminaryHeaders = res.getHeaders(); // Keeps only whitelisted headers const headers = Object.keys(preliminaryHeaders) .filter((key) => headerWhitelist.includes(key)) .reduce((obj, key) => { obj[key] = preliminaryHeaders[key]; return obj; }, {}); return { statusCode, body, headers, }; } }; IdempotencyService = __decorate([ autobind_decorator_1.boundClass ], IdempotencyService); exports.IdempotencyService = IdempotencyService;