├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── index.js ├── karma.conf.js ├── karma ├── karma.config.js ├── karma.entry.js └── webpack.test.js ├── package-lock.json ├── package.json ├── src ├── ExponentialTime.ts ├── GenericRestClient.ts ├── SimpleRestClients.ts ├── SimpleWebRequest.ts └── utils.ts ├── test ├── ExponentialTime.spec.ts ├── GenericRestClient.spec.ts ├── SimpleWebRequest.spec.ts └── helpers.ts ├── tsconfig.json └── tsconfig.test.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "project": [ 4 | "./tsconfig.json", 5 | "./tsconfig.test.json" 6 | ] 7 | }, 8 | "extends": ["skype"], 9 | "env": { 10 | "jasmine": true 11 | }, 12 | 13 | "rules": { 14 | "no-console": ["error", { "allow": ["error", "warn"] }], 15 | "prefer-const": "off", 16 | "@typescript-eslint/no-floating-promises": "off", 17 | "dot-notation": "off", 18 | "arrow-body-style": "off" 19 | } 20 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /src/.vs 4 | *.user 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /src/.vs 3 | /src/bin 4 | /src/obj 5 | *.user 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "node" 5 | 6 | dist: trusty 7 | 8 | sudo: false 9 | 10 | addons: 11 | chrome: stable 12 | 13 | install: 14 | - npm --version 15 | - npm i 16 | 17 | script: 18 | - npm run lint 19 | - npm run test 20 | - npm run build 21 | 22 | cache: 23 | directories: 24 | - node_modules 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | 6 | ## [0.2.0] - 2018-08-29 7 | ### Added 8 | - Unit tests verifying queueing/blocking requests and executing them in order 9 | 10 | ### Changed 11 | - `GenericRestClient._blockRequestUntil` is now called every time when the request is on top of the pending requests queue rather than only once 12 | - If `GenericRestClient._blockRequestUntil` rejects, the whole request is properly rejected 13 | - All the `assert`s now properly clear the request queues before throwing 14 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Microsoft Corporation 2 | 3 | All rights reserved. 4 | 5 | The MIT License (MIT) 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleRestClients 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/Microsoft/SimpleRestClients/blob/master/LICENSE) [![npm version](https://img.shields.io/npm/v/simplerestclients.svg?style=flat-square)](https://www.npmjs.com/package/simplerestclients) [![Build Status](https://img.shields.io/travis/Microsoft/SimpleRestClients/master.svg?style=flat-square)](https://travis-ci.org/Microsoft/SimpleRestClients) [![npm downloads](https://img.shields.io/npm/dm/simplerestclients.svg?style=flat-square)](https://www.npmjs.com/package/simplerestclients) ![npm bundle size (minified)](https://img.shields.io/bundlephobia/min/simplerestclients.svg?style=flat-square) ![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/simplerestclients.svg?style=flat-square) 4 | 5 | > A simple set of wrappers for RESTful calls. 6 | 7 | ## Installation 8 | 9 | ```shell 10 | npm install --save simplerestclients 11 | ``` 12 | 13 | ## SimpleRestClients consists of two modules: 14 | 15 | ### `SimpleWebRequest` 16 | 17 | Wraps a single web request. Takes an options structure with overrides for priorization, delays, retry logic, error handling, etc. Has 18 | an `abort()` method to cancel the request early (will result in a rejected promise from the `start()` method). 19 | 20 | ### `GenericRestClient` 21 | 22 | Wraps SimpleWebRequest for usage across a single RESTful service. In our codebase, we have several specific RESTful service interaction 23 | classes that each implement GenericRestClient so that all of the requests get the same error handling, authentication, header-setting, 24 | etc. 25 | 26 | ## GenericRestClient Sample Usage 27 | 28 | ```typescript 29 | import { GenericRestClient, ApiCallOptions, Headers } from 'simplerestclients'; 30 | 31 | interface User { 32 | id: string; 33 | firstName: string; 34 | lastName: string; 35 | } 36 | 37 | class MyRestClient extends GenericRestClient { 38 | constructor(private _appId: string) { 39 | super('https://myhost.com/api/v1/'); 40 | } 41 | 42 | // Override _getHeaders to append a custom header with the app ID. 43 | protected _getHeaders(options: ApiCallOptions): Headers { 44 | return { ...super._getHeaders(options), 'X-AppId': this._appId }; 45 | } 46 | 47 | // Define public methods that expose the APIs provided through the REST service. 48 | getAllUsers(): Promise { 49 | return this.performApiGet('users'); 50 | } 51 | 52 | getUserById(id: string): Promise { 53 | return this.performApiGet(`user/${ id }`); 54 | } 55 | 56 | setUser(user: User): Promise { 57 | return this.performApiPut(`user/${ user.id }`, user); 58 | } 59 | } 60 | ``` 61 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * index.js 3 | * Copyright: Microsoft 2016 4 | * 5 | * Points at typescript source for using with webpack/TSC. 6 | */ 7 | 8 | module.exports = require('./dist/SimpleRestClients'); 9 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./karma/karma.config'); 2 | -------------------------------------------------------------------------------- /karma/karma.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('./webpack.test'); 2 | 3 | module.exports = config => ( 4 | config.set({ 5 | basePath: '', 6 | frameworks: ['jasmine-ajax', 'jasmine'], 7 | reporters: ['spec', 'kjhtml'], 8 | browsers: [ 9 | process.env.TRAVIS 10 | ? 'ChromeHeadlessNoSandbox' 11 | : 'Chrome', 12 | ], 13 | plugins: [ 14 | 'karma-jasmine-html-reporter', 15 | 'karma-sourcemap-loader', 16 | 'karma-chrome-launcher', 17 | 'karma-spec-reporter', 18 | 'karma-jasmine-ajax', 19 | 'karma-jasmine', 20 | 'karma-webpack', 21 | ], 22 | 23 | customLaunchers: { 24 | ChromeHeadlessNoSandbox: { 25 | base: 'ChromeHeadless', 26 | flags: ['--no-sandbox'], 27 | } 28 | }, 29 | 30 | preprocessors: { 31 | './karma/karma.entry.js': ['webpack', 'sourcemap'], 32 | }, 33 | 34 | files: [ 35 | { pattern: './karma/karma.entry.js', watched: false }, 36 | ], 37 | 38 | logLevel: config.LOG_INFO, 39 | colors: true, 40 | mime: { 41 | 'text/x-typescript': ['ts'], 42 | }, 43 | 44 | webpack, 45 | 46 | webpackMiddleware: { 47 | stats: 'errors-only', 48 | } 49 | }) 50 | ); 51 | -------------------------------------------------------------------------------- /karma/karma.entry.js: -------------------------------------------------------------------------------- 1 | const context = require 2 | .context('../test/', true, /\.spec\.ts$/); 3 | 4 | context 5 | .keys() 6 | .forEach(context); 7 | -------------------------------------------------------------------------------- /karma/webpack.test.js: -------------------------------------------------------------------------------- 1 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 2 | const path = require('path'); 3 | const ROOT_PATH = path.resolve(__dirname, '..'); 4 | const CONFIG_PATH = path.resolve(ROOT_PATH, 'tsconfig.test.json'); 5 | 6 | module.exports = { 7 | devtool: 'inline-source-map', 8 | mode: 'development', 9 | 10 | resolve: { 11 | extensions: ['.js', '.ts'] 12 | }, 13 | 14 | module: { 15 | rules: [{ 16 | test: /\.ts$/, 17 | loader: 'ts-loader', 18 | options: { 19 | transpileOnly: true, 20 | configFile: CONFIG_PATH, 21 | context: ROOT_PATH, 22 | }, 23 | exclude: /node_modules/ 24 | }] 25 | }, 26 | 27 | plugins: [ 28 | new ForkTsCheckerWebpackPlugin({ tsconfig: CONFIG_PATH }), 29 | ] 30 | }; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simplerestclients", 3 | "version": "1.0.0", 4 | "description": "A library of components for accessing RESTful services with javascript/typescript.", 5 | "author": "David de Regt ", 6 | "scripts": { 7 | "prepare": "tsc", 8 | "clean": "rimraf dist", 9 | "build": "npm run lint && tsc", 10 | "lint": "eslint --config .eslintrc --ext .ts src test", 11 | "lint:fix": "npm run lint -- --fix", 12 | "test": "npm run clean && karma start --singleRun", 13 | "test:watch": "npm run clean && karma start", 14 | "test:browser": "npm run clean && karma start --browsers=Chrome --single-run=false --auto-watch" 15 | }, 16 | "dependencies": {}, 17 | "devDependencies": { 18 | "@types/faker": "4.1.8", 19 | "@types/jasmine": "3.5.0", 20 | "@types/jasmine-ajax": "3.3.0", 21 | "@typescript-eslint/eslint-plugin": "2.11.0", 22 | "@typescript-eslint/parser": "2.11.0", 23 | "eslint": "6.7.2", 24 | "eslint-config-skype": "1.4.0", 25 | "eslint-plugin-import": "^2.19.1", 26 | "faker": "4.1.0", 27 | "fork-ts-checker-webpack-plugin": "3.1.1", 28 | "jasmine": "3.5.0", 29 | "jasmine-core": "3.5.0", 30 | "karma": "6.3.16", 31 | "karma-chrome-launcher": "3.1.0", 32 | "karma-jasmine": "2.0.1", 33 | "karma-jasmine-ajax": "0.1.13", 34 | "karma-jasmine-html-reporter": "1.4.2", 35 | "karma-sourcemap-loader": "0.3.7", 36 | "karma-spec-reporter": "0.0.32", 37 | "karma-webpack": "5.0.0", 38 | "rimraf": "3.0.0", 39 | "ts-loader": "9.4.2", 40 | "typescript": "3.7.3", 41 | "webpack": "5.75.0" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/Microsoft/SimpleRestClients" 46 | }, 47 | "main": "index.js", 48 | "bugs": { 49 | "url": "https://github.com/Microsoft/SimpleRestClients/issues" 50 | }, 51 | "keywords": [ 52 | "simplerestclient", 53 | "rest", 54 | "restclient", 55 | "xmlhttprequest", 56 | "superagent" 57 | ], 58 | "license": "MIT", 59 | "types": "dist/SimpleRestClients.d.ts" 60 | } 61 | -------------------------------------------------------------------------------- /src/ExponentialTime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ExponentialTime.ts 3 | * Author: David de Regt 4 | * Copyright: Microsoft 2016 5 | * 6 | * Timer to be used for exponential backoff. Integrates jitter so as to not slam all services at the same time after backoffs. 7 | */ 8 | 9 | import { assert } from './utils'; 10 | 11 | export const DEFAULT_TIME_GROW_FACTOR = 2.7182818284590451; 12 | export const DEFAULT_TIME_JITTER = 0.11962656472; 13 | 14 | export class ExponentialTime { 15 | private _currentTime: number; 16 | private _incrementCount: number; 17 | 18 | /** 19 | * @param initialTime multiplier of exponent 20 | * @param maxTime delays won't be greater than this 21 | * @param growFactor base of exponent 22 | * @param jitterFactor 23 | */ 24 | constructor(private _initialTime: number, 25 | private _maxTime: number, 26 | private _growFactor = DEFAULT_TIME_GROW_FACTOR, 27 | private _jitterFactor = DEFAULT_TIME_JITTER) { 28 | 29 | assert(this._initialTime > 0, 'Initial delay must be positive'); 30 | assert(this._maxTime > 0, 'Delay upper bound must be positive'); 31 | assert(this._growFactor >= 0, 'Ratio must be non-negative'); 32 | assert(this._jitterFactor >= 0, 'Jitter factor must be non-negative'); 33 | 34 | this.reset(); 35 | } 36 | 37 | reset(): void { 38 | this._incrementCount = 0; 39 | 40 | // Differ from java impl -- give it some initial jitter 41 | this._currentTime = Math.round(this._initialTime * (1 + Math.random() * this._jitterFactor)); 42 | } 43 | 44 | getTime(): number { 45 | return this._currentTime; 46 | } 47 | 48 | getIncrementCount(): number { 49 | return this._incrementCount; 50 | } 51 | 52 | calculateNext(): number { 53 | let delay = this._currentTime * this._growFactor; 54 | 55 | if (delay > this._maxTime) { 56 | delay = this._maxTime; 57 | } 58 | 59 | if (this._jitterFactor < 0.00001) { 60 | this._currentTime = delay; 61 | } else { 62 | this._currentTime = Math.round(Math.random() * delay * this._jitterFactor + delay); 63 | } 64 | 65 | if (this._currentTime < this._initialTime) { 66 | this._currentTime = this._initialTime; 67 | } 68 | 69 | if (this._currentTime > this._maxTime) { 70 | this._currentTime = this._maxTime; 71 | } 72 | 73 | this._incrementCount++; 74 | return this._currentTime; 75 | } 76 | 77 | /** 78 | * @return first call returns initialTime, next calls will return initialTime*growFactor^n + jitter 79 | */ 80 | getTimeAndCalculateNext(): number { 81 | const res = this.getTime(); 82 | this.calculateNext(); 83 | return res; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/GenericRestClient.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GenericRestClient.ts 3 | * Author: David de Regt 4 | * Copyright: Microsoft 2015 5 | * 6 | * Base client type for accessing RESTful services 7 | */ 8 | 9 | import { isString } from './utils'; 10 | import { WebRequestOptions, SimpleWebRequest, WebResponse, Headers } from './SimpleWebRequest'; 11 | 12 | export type HttpAction = 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH'; 13 | 14 | export interface ApiCallOptions extends WebRequestOptions { 15 | backendUrl?: string; 16 | excludeEndpointUrl?: boolean; 17 | eTag?: string; 18 | } 19 | 20 | export interface ETagResponse { 21 | // Indicates whether the provided ETag matched. If true, the response is undefined. 22 | eTagMatched?: boolean; 23 | 24 | // If the ETag didn't match, the response contains the updated information. 25 | response?: T; 26 | 27 | // The updated ETag value. 28 | eTag?: string; 29 | } 30 | 31 | export interface ApiCallResponse { 32 | req: SimpleWebRequest>; 33 | promise: Promise>>; 34 | } 35 | 36 | export class GenericRestClient { 37 | 38 | protected _endpointUrl: string; 39 | 40 | protected _defaultOptions: ApiCallOptions = { 41 | excludeEndpointUrl: false, 42 | withCredentials: false, 43 | retries: 0, 44 | }; 45 | 46 | constructor(endpointUrl: string) { 47 | this._endpointUrl = endpointUrl; 48 | } 49 | 50 | protected _performApiCall(apiPath: string, 51 | action: HttpAction, 52 | objToPost: any, 53 | givenOptions: Partial = {}): ApiCallResponse { 54 | 55 | const options: ApiCallOptions & Partial = { ...this._defaultOptions, ...givenOptions }; 56 | if (objToPost) { 57 | options.sendData = objToPost; 58 | } 59 | 60 | if (options.eTag) { 61 | if (!options.augmentHeaders) { 62 | options.augmentHeaders = {}; 63 | } 64 | options.augmentHeaders['If-None-Match'] = options.eTag; 65 | } 66 | 67 | if (!options.contentType) { 68 | options.contentType = isString(options.sendData) ? 'form' : 'json'; 69 | } 70 | 71 | const finalUrl = options.excludeEndpointUrl ? apiPath : this._endpointUrl + apiPath; 72 | 73 | const req = new SimpleWebRequest>( 74 | action, 75 | finalUrl, 76 | options, 77 | () => this._getHeaders(options), 78 | () => this._blockRequestUntil(options), 79 | ); 80 | 81 | const promise = req.start().then(response => { 82 | this._processSuccessResponse(response); 83 | return response; 84 | }); 85 | 86 | return { 87 | req, 88 | promise, 89 | }; 90 | } 91 | 92 | protected _getHeaders(options: ApiCallOptions & Partial): Headers { 93 | // Virtual function -- No-op by default 94 | return {}; 95 | } 96 | 97 | // Override (but make sure to call super and chain appropriately) this function if you want to add more blocking criteria. 98 | // Also, this might be called multiple times to check if the conditions changed 99 | protected _blockRequestUntil(options: ApiCallOptions & Partial): Promise | undefined { 100 | // No-op by default 101 | return undefined; 102 | } 103 | 104 | // Override this function to process any generic headers that come down with a successful response 105 | protected _processSuccessResponse(resp: WebResponse>): void { 106 | // No-op by default 107 | } 108 | 109 | performApiGet(apiPath: string, options?: ApiCallOptions & Partial): Promise { 110 | return this 111 | .performApiGetDetailed(apiPath, options) 112 | .promise.then(resp => resp.body); 113 | } 114 | 115 | performApiGetDetailed(apiPath: string, options?: ApiCallOptions & Partial): 116 | ApiCallResponse> { 117 | return this._performApiCall(apiPath, 'GET', undefined, options); 118 | } 119 | 120 | performApiPost(apiPath: string, objToPost: any, options?: ApiCallOptions & Partial): Promise { 121 | return this 122 | .performApiPostDetailed(apiPath, objToPost, options) 123 | .promise.then(resp => resp.body); 124 | } 125 | 126 | performApiPostDetailed(apiPath: string, objToPost: any, options?: ApiCallOptions & Partial): 127 | ApiCallResponse> { 128 | return this._performApiCall(apiPath, 'POST', objToPost, options); 129 | } 130 | 131 | performApiPatch(apiPath: string, objToPatch: any, options?: ApiCallOptions & Partial): Promise { 132 | return this 133 | .performApiPatchDetailed(apiPath, objToPatch, options) 134 | .promise.then(resp => resp.body); 135 | } 136 | 137 | performApiPatchDetailed(apiPath: string, objToPatch: any, options?: ApiCallOptions & Partial): 138 | ApiCallResponse> { 139 | return this._performApiCall(apiPath, 'PATCH', objToPatch, options); 140 | } 141 | 142 | performApiPut(apiPath: string, objToPut: any, options?: ApiCallOptions & Partial): Promise { 143 | return this 144 | .performApiPutDetailed(apiPath, objToPut, options) 145 | .promise.then(resp => resp.body); 146 | } 147 | 148 | performApiPutDetailed(apiPath: string, objToPut: any, options?: ApiCallOptions & Partial): 149 | ApiCallResponse> { 150 | return this._performApiCall(apiPath, 'PUT', objToPut, options); 151 | } 152 | 153 | performApiDelete(apiPath: string, objToDelete?: any, options?: ApiCallOptions & Partial): Promise { 154 | return this 155 | .performApiDeleteDetailed(apiPath, objToDelete, options) 156 | .promise.then(resp => resp.body); 157 | } 158 | 159 | performApiDeleteDetailed(apiPath: string, objToDelete: any, options?: ApiCallOptions & Partial): 160 | ApiCallResponse> { 161 | return this._performApiCall(apiPath, 'DELETE', objToDelete, options); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/SimpleRestClients.ts: -------------------------------------------------------------------------------- 1 | export * from './ExponentialTime'; 2 | export * from './GenericRestClient'; 3 | export * from './SimpleWebRequest'; 4 | -------------------------------------------------------------------------------- /src/SimpleWebRequest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SimpleWebRequest.ts 3 | * Author: David de Regt 4 | * Copyright: Microsoft 2016 5 | * 6 | * Simple client for issuing web requests. 7 | */ 8 | 9 | import { attempt, isObject, isString, remove, assert, clone } from './utils'; 10 | import { ExponentialTime } from './ExponentialTime'; 11 | 12 | interface Dictionary { 13 | [key: string]: T; 14 | } 15 | 16 | export interface Headers extends Dictionary {} 17 | export interface Params extends Dictionary {} 18 | 19 | export interface WebTransportResponseBase { 20 | url: string; 21 | method: string; 22 | statusCode: number; 23 | statusText: string | undefined; 24 | headers: Headers; 25 | responseParsingException?: Error; 26 | } 27 | 28 | export interface WebTransportResponse extends WebTransportResponseBase { 29 | body: TBody; 30 | } 31 | 32 | export interface WebTransportErrorResponse extends WebTransportResponseBase { 33 | body: any; 34 | canceled: boolean; 35 | timedOut: boolean; 36 | } 37 | 38 | export interface RestRequestInResponse { 39 | requestOptions: TOptions; 40 | requestHeaders: Headers; 41 | } 42 | 43 | export interface WebResponseBase 44 | extends WebTransportResponseBase, RestRequestInResponse {} 45 | 46 | export interface WebErrorResponse 47 | extends WebTransportErrorResponse, RestRequestInResponse {} 48 | 49 | export interface WebResponse 50 | extends WebTransportResponse, RestRequestInResponse {} 51 | 52 | export enum WebRequestPriority { 53 | DontCare = 0, 54 | Low = 1, 55 | Normal = 2, 56 | High = 3, 57 | Critical = 4 58 | } 59 | 60 | export enum ErrorHandlingType { 61 | // Ignore retry policy, if any, and fail immediately 62 | DoNotRetry, 63 | 64 | // Retry immediately, without counting it as a failure (used when you've made some sort of change to the ) 65 | RetryUncountedImmediately, 66 | 67 | // Retry with exponential backoff, but don't count it as a failure (for 429 handling) 68 | RetryUncountedWithBackoff, 69 | 70 | // Use standard retry policy (count it as a failure, exponential backoff as policy dictates) 71 | RetryCountedWithBackoff, 72 | 73 | // Return this if you need to satisfy some condition before this request will retry (then call .resumeRetrying()). 74 | PauseUntilResumed 75 | } 76 | 77 | export interface NativeBlobFileData { 78 | uri: string; 79 | size: number; 80 | name: string; 81 | type: string; 82 | } 83 | 84 | export interface NativeFileData { 85 | file: NativeBlobFileData | File; 86 | } 87 | 88 | export interface XMLHttpRequestProgressEvent extends ProgressEvent { 89 | lengthComputable: boolean; 90 | loaded: number; 91 | path: string[]; 92 | percent: number; 93 | position: number; 94 | total: number; 95 | totalSize: number; 96 | } 97 | 98 | export type SendDataType = Params | string | NativeFileData; 99 | 100 | export interface WebRequestOptions { 101 | withCredentials?: boolean; 102 | retries?: number; 103 | priority?: WebRequestPriority; 104 | timeout?: number; 105 | acceptType?: string; 106 | customResponseType?: XMLHttpRequestResponseType; 107 | contentType?: string; 108 | sendData?: SendDataType; 109 | /* Deprecated: use overrideGetHeaders */ headers?: Headers; 110 | 111 | // Used instead of calling getHeaders. 112 | overrideGetHeaders?: Headers; 113 | // Overrides all other headers. 114 | augmentHeaders?: Headers; 115 | 116 | streamingDownloadProgress?: (responseText: string) => void; 117 | 118 | onProgress?: (progressEvent: XMLHttpRequestProgressEvent) => void; 119 | 120 | customErrorHandler?: (webRequest: SimpleWebRequestBase, errorResponse: WebErrorResponse) => ErrorHandlingType; 121 | augmentErrorResponse?: (resp: WebErrorResponse) => void; 122 | } 123 | 124 | function isJsonContentType(ct: string): boolean { 125 | return !!ct && ct.indexOf('application/json') === 0; 126 | } 127 | 128 | function isFormContentType(ct: string): boolean { 129 | return !!ct && ct.indexOf('application/x-www-form-urlencoded') === 0; 130 | } 131 | 132 | function isFormDataContentType(ct: string): boolean { 133 | return !!ct && ct.indexOf('multipart/form-data') === 0; 134 | } 135 | 136 | export const DefaultOptions: WebRequestOptions = { 137 | priority: WebRequestPriority.Normal, 138 | }; 139 | 140 | // eslint-disable-next-line @typescript-eslint/interface-name-prefix 141 | export interface ISimpleWebRequestOptions { 142 | // Maximum executing requests allowed. Other requests will be queued until free spots become available. 143 | MaxSimultaneousRequests: number; 144 | 145 | // We've seen cases where requests have reached completion but callbacks haven't been called (typically during failed 146 | // CORS preflight) Directly call the respond function to kick these requests and unblock the reserved slot in our queues 147 | HungRequestCleanupIntervalMs: number; 148 | 149 | // Use this to shim calls to setTimeout/clearTimeout with any other service/local function you want. 150 | setTimeout: (callback: () => void, timeoutMs?: number) => number; 151 | clearTimeout: (id: number) => void; 152 | } 153 | 154 | export let SimpleWebRequestOptions: ISimpleWebRequestOptions = { 155 | MaxSimultaneousRequests: 5, 156 | HungRequestCleanupIntervalMs: 10000, 157 | 158 | setTimeout: (callback: () => void, timeoutMs?: number) => setTimeout(callback, timeoutMs), 159 | clearTimeout: (id: number) => clearTimeout(id), 160 | }; 161 | 162 | export function DefaultErrorHandler(webRequest: SimpleWebRequestBase, errResp: WebTransportErrorResponse): ErrorHandlingType { 163 | if (errResp.canceled || !errResp.statusCode || errResp.statusCode >= 400 && errResp.statusCode < 600) { 164 | // Fail canceled/0/4xx/5xx requests immediately. 165 | // These are permenent failures, and shouldn't have retry logic applied to them. 166 | return ErrorHandlingType.DoNotRetry; 167 | } 168 | 169 | // Possible transient failure -- just retry as normal with backoff. 170 | return ErrorHandlingType.RetryCountedWithBackoff; 171 | } 172 | 173 | // Note: The ordering of this enum is used for detection logic 174 | const enum FeatureSupportStatus { 175 | Unknown, 176 | Detecting, 177 | NotSupported, 178 | Supported 179 | } 180 | 181 | // List of pending requests, sorted from most important to least important (numerically descending) 182 | let requestQueue: SimpleWebRequestBase[] = []; 183 | 184 | // List of requests blocked on _blockUNtil promises 185 | let blockedList: SimpleWebRequestBase[] = []; 186 | 187 | // List of executing (non-finished) requests -- only to keep track of number of requests to compare to the max 188 | let executingList: SimpleWebRequestBase[] = []; 189 | 190 | let hungRequestCleanupTimer: number | undefined; 191 | 192 | // Feature flag checkers for whether the current environment supports various types of XMLHttpRequest features 193 | let onLoadErrorSupportStatus = FeatureSupportStatus.Unknown; 194 | let timeoutSupportStatus = FeatureSupportStatus.Unknown; 195 | 196 | export abstract class SimpleWebRequestBase { 197 | protected _xhr: XMLHttpRequest | undefined; 198 | protected _xhrRequestHeaders: Headers | undefined; 199 | protected _requestTimeoutTimer: number | undefined; 200 | protected _options: TOptions; 201 | 202 | protected _aborted = false; 203 | protected _timedOut = false; 204 | protected _paused = false; 205 | protected _created = Date.now(); 206 | 207 | // De-dupe result handling for two reasons so far: 208 | // 1. Various platforms have bugs where they double-resolves aborted xmlhttprequests 209 | // 2. Safari seems to have a bug where sometimes it double-resolves happily-completed xmlhttprequests 210 | protected _finishHandled = false; 211 | 212 | protected _retryTimer: number | undefined; 213 | protected _retryExponentialTime = new ExponentialTime(1000, 300000); 214 | 215 | constructor(protected _action: string, 216 | protected _url: string, 217 | protected readonly options: TOptions, 218 | protected readonly _getHeaders?: () => Headers, 219 | protected readonly _blockRequestUntil?: () => Promise | undefined) { 220 | 221 | this._options = { ...DefaultOptions, ...options }; 222 | } 223 | 224 | getPriority(): WebRequestPriority { 225 | return this._options.priority || WebRequestPriority.DontCare; 226 | } 227 | 228 | abstract abort(): void; 229 | 230 | protected static checkQueueProcessing(): void { 231 | while (executingList.length < SimpleWebRequestOptions.MaxSimultaneousRequests) { 232 | const req = requestQueue.shift(); 233 | if (!req) { 234 | return; 235 | } 236 | 237 | blockedList.push(req); 238 | const blockPromise = (req._blockRequestUntil && req._blockRequestUntil()) || Promise.resolve(); 239 | blockPromise.then(() => { 240 | remove(blockedList, req); 241 | 242 | if (executingList.length < SimpleWebRequestOptions.MaxSimultaneousRequests && !req._aborted) { 243 | executingList.push(req); 244 | SimpleWebRequest._scheduleHungRequestCleanupIfNeeded(); 245 | req._fire(); 246 | } else { 247 | req._enqueue(); 248 | } 249 | }, (err: any) => { 250 | remove(blockedList, req); 251 | 252 | // fail the request if the block promise is rejected 253 | req._respond('_blockRequestUntil rejected: ' + err); 254 | }); 255 | } 256 | } 257 | 258 | private static _scheduleHungRequestCleanupIfNeeded(): void { 259 | // Schedule a cleanup timer if needed 260 | if (executingList.length > 0 && hungRequestCleanupTimer === undefined) { 261 | hungRequestCleanupTimer = SimpleWebRequestOptions.setTimeout(this._hungRequestCleanupTimerCallback, 262 | SimpleWebRequestOptions.HungRequestCleanupIntervalMs); 263 | } else if (executingList.length === 0 && hungRequestCleanupTimer) { 264 | SimpleWebRequestOptions.clearTimeout(hungRequestCleanupTimer); 265 | hungRequestCleanupTimer = undefined; 266 | } 267 | } 268 | 269 | private static _hungRequestCleanupTimerCallback = (): void => { 270 | hungRequestCleanupTimer = undefined; 271 | executingList.filter(request => { 272 | if (request._xhr && request._xhr.readyState === 4) { 273 | console.warn('SimpleWebRequests found a completed XHR that hasn\'t invoked it\'s callback functions, manually responding'); 274 | return true; 275 | } 276 | return false; 277 | }).forEach(request => { 278 | // We need to respond outside of the initial iteration across the array since _respond mutates exeutingList 279 | request._respond(); 280 | }); 281 | 282 | SimpleWebRequest._scheduleHungRequestCleanupIfNeeded(); 283 | }; 284 | 285 | protected _removeFromQueue(): void { 286 | // Only pull from request queue and executing queue here - pulling from the blocked queue can result in requests 287 | // being queued up multiple times if _respond fires more than once (it shouldn't, but does happen in the wild) 288 | remove(executingList, this); 289 | remove(requestQueue, this); 290 | } 291 | 292 | protected _assertAndClean(expression: any, message: string): void { 293 | if (!expression) { 294 | this._removeFromQueue(); 295 | console.error(message); 296 | assert(expression, message); 297 | } 298 | } 299 | 300 | // TSLint thinks that this function is unused. Silly tslint. 301 | // tslint:disable-next-line 302 | private _fire(): void { 303 | this._xhr = new XMLHttpRequest(); 304 | this._xhrRequestHeaders = {}; 305 | 306 | // xhr.open() can throw an exception for a CSP violation. 307 | const openError = attempt(() => { 308 | // Apparently you're supposed to open the connection before adding events to it. If you don't, the node.js implementation 309 | // of XHR actually calls this.abort() at the start of open()... Bad implementations, hooray. 310 | this._xhr!!!.open(this._action, this._url, true); 311 | }); 312 | 313 | if (openError) { 314 | this._respond(openError.toString()); 315 | return; 316 | } 317 | 318 | if (this._options.timeout) { 319 | const timeoutSupported = timeoutSupportStatus; 320 | // Use manual timer if we don't know about timeout support 321 | if (timeoutSupported !== FeatureSupportStatus.Supported) { 322 | this._assertAndClean(!this._requestTimeoutTimer, 'Double-fired requestTimeoutTimer'); 323 | this._requestTimeoutTimer = SimpleWebRequestOptions.setTimeout(() => { 324 | this._requestTimeoutTimer = undefined; 325 | 326 | this._timedOut = true; 327 | this.abort(); 328 | }, this._options.timeout); 329 | } 330 | 331 | // This is our first completed request. Use it for feature detection 332 | if (timeoutSupported === FeatureSupportStatus.Supported || timeoutSupported <= FeatureSupportStatus.Detecting) { 333 | // timeout and ontimeout are part of the XMLHttpRequest Level 2 spec, should be supported in most modern browsers 334 | this._xhr.timeout = this._options.timeout; 335 | this._xhr.ontimeout = () => { 336 | timeoutSupportStatus = FeatureSupportStatus.Supported; 337 | if (timeoutSupported !== FeatureSupportStatus.Supported) { 338 | // When this request initially fired we didn't know about support, bail & let the fallback method handle this 339 | return; 340 | } 341 | this._timedOut = true; 342 | // Set aborted flag to match simple timer approach, which aborts the request and results in an _respond call 343 | this._aborted = true; 344 | this._respond('TimedOut'); 345 | }; 346 | } 347 | } 348 | 349 | const onLoadErrorSupported = onLoadErrorSupportStatus; 350 | 351 | // Use onreadystatechange if we don't know about onload support or it onload is not supported 352 | if (onLoadErrorSupported !== FeatureSupportStatus.Supported) { 353 | if (onLoadErrorSupported === FeatureSupportStatus.Unknown) { 354 | // Set global status to detecting, leave local state so we can set a timer on finish 355 | onLoadErrorSupportStatus = FeatureSupportStatus.Detecting; 356 | } 357 | this._xhr.onreadystatechange = () => { 358 | if (!this._xhr) { 359 | return; 360 | } 361 | 362 | if (this._xhr.readyState === 3 && this._options.streamingDownloadProgress && !this._aborted) { 363 | // This callback may result in cancelling the connection, so keep that in mind with any handling after it 364 | // if we decide to stop using the return after this someday down the line. i.e. this._xhr may be undefined 365 | // when we come back from this call. 366 | this._options.streamingDownloadProgress(this._xhr.responseText); 367 | return; 368 | } 369 | 370 | if (this._xhr.readyState !== 4) { 371 | // Wait for it to finish 372 | return; 373 | } 374 | 375 | // This is the first request completed (unknown status when fired, detecting now), use it for detection 376 | if (onLoadErrorSupported === FeatureSupportStatus.Unknown && 377 | onLoadErrorSupportStatus === FeatureSupportStatus.Detecting) { 378 | // If onload hasn't fired within 10 seconds of completion, detect as not supported 379 | SimpleWebRequestOptions.setTimeout(() => { 380 | if (onLoadErrorSupportStatus !== FeatureSupportStatus.Supported) { 381 | onLoadErrorSupportStatus = FeatureSupportStatus.NotSupported; 382 | } 383 | }, 10000); 384 | } 385 | 386 | this._respond(); 387 | }; 388 | } else if (this._options.streamingDownloadProgress) { 389 | // If we support onload and such, but have a streaming download handler, still trap the oRSC. 390 | this._xhr.onreadystatechange = () => { 391 | if (!this._xhr) { 392 | return; 393 | } 394 | 395 | if (this._xhr.readyState === 3 && this._options.streamingDownloadProgress && !this._aborted) { 396 | // This callback may result in cancelling the connection, so keep that in mind with any handling after it 397 | // if we decide to stop using the return after this someday down the line. i.e. this._xhr may be undefined 398 | // when we come back from this call. 399 | this._options.streamingDownloadProgress(this._xhr.responseText); 400 | } 401 | }; 402 | } 403 | 404 | if (onLoadErrorSupported !== FeatureSupportStatus.NotSupported) { 405 | // onLoad and onError are part of the XMLHttpRequest Level 2 spec, should be supported in most modern browsers 406 | this._xhr.onload = () => { 407 | onLoadErrorSupportStatus = FeatureSupportStatus.Supported; 408 | if (onLoadErrorSupported !== FeatureSupportStatus.Supported) { 409 | // When this request initially fired we didn't know about support, bail & let the fallback method handle this 410 | return; 411 | } 412 | this._respond(); 413 | }; 414 | this._xhr.onerror = () => { 415 | onLoadErrorSupportStatus = FeatureSupportStatus.Supported; 416 | if (onLoadErrorSupported !== FeatureSupportStatus.Supported) { 417 | // When this request initially fired we didn't know about support, bail & let the fallback method handle this 418 | return; 419 | } 420 | this._respond(); 421 | }; 422 | } 423 | 424 | this._xhr.onabort = () => { 425 | // If the browser cancels us (page navigation or whatever), it sometimes calls both the readystatechange and this, 426 | // so make sure we know that this is an abort. 427 | this._aborted = true; 428 | this._respond('Aborted'); 429 | }; 430 | 431 | if (this._xhr.upload && this._options.onProgress) { 432 | this._xhr.upload.onprogress = this._options.onProgress as (ev: ProgressEvent) => void; 433 | } 434 | 435 | const acceptType = this._options.acceptType || 'json'; 436 | const responseType = this._options.customResponseType || SimpleWebRequestBase._getResponseType(acceptType); 437 | const responseTypeError = attempt(() => { 438 | this._xhr!!!.responseType = responseType; 439 | }); 440 | 441 | if (responseTypeError) { 442 | // WebKit added support for the json responseType value on 09/03/2013 443 | // https://bugs.webkit.org/show_bug.cgi?id=73648. 444 | // Versions of Safari prior to 7 and Android 4 Samsung borwsers are 445 | // known to throw an Error when setting the value "json" as the response type. 446 | // 447 | // The json response type can be ignored if not supported, because JSON payloads 448 | // are handled by mapBody anyway 449 | if (responseType !== 'json') { 450 | throw responseTypeError; 451 | } 452 | } 453 | 454 | SimpleWebRequest._setRequestHeader(this._xhr, this._xhrRequestHeaders, 'Accept', SimpleWebRequestBase.mapContentType(acceptType)); 455 | 456 | this._xhr.withCredentials = !!this._options.withCredentials; 457 | 458 | const nextHeaders = this.getRequestHeaders(); 459 | // check/process headers 460 | let headersCheck: Dictionary = {}; 461 | 462 | Object.keys(nextHeaders).forEach(key => { 463 | const value = nextHeaders[key]; 464 | const headerLower = key.toLowerCase(); 465 | 466 | if (headerLower === 'content-type') { 467 | this._assertAndClean(false, `Don't set Content-Type with options.headers -- use it with the options.contentType property`); 468 | return; 469 | } 470 | 471 | if (headerLower === 'accept') { 472 | this._assertAndClean(false, `Don't set Accept with options.headers -- use it with the options.acceptType property`); 473 | return; 474 | } 475 | 476 | this._assertAndClean(!headersCheck[headerLower], `Setting duplicate header key: ${ headersCheck[headerLower] } and ${ key }`); 477 | 478 | if (value === undefined || value === null) { 479 | console.warn(`Tried to set header "${ key }" on request with "${ value }" value, header will be dropped`); 480 | return; 481 | } 482 | 483 | headersCheck[headerLower] = true; 484 | 485 | SimpleWebRequest._setRequestHeader(this._xhr!!!, this._xhrRequestHeaders!!!, key, value); 486 | }); 487 | 488 | if (this._options.sendData) { 489 | const contentType = SimpleWebRequestBase.mapContentType(this._options.contentType || 'json'); 490 | SimpleWebRequest._setRequestHeader(this._xhr, this._xhrRequestHeaders, 'Content-Type', contentType); 491 | 492 | const sendData = SimpleWebRequestBase.mapBody(this._options.sendData, contentType); 493 | this._xhr.send(sendData as BodyInit); 494 | } else { 495 | this._xhr.send(); 496 | } 497 | } 498 | 499 | private static _setRequestHeader(xhr: XMLHttpRequest, xhrRequestHeaders: Headers, key: string, val: string): void { 500 | xhr.setRequestHeader(key, val); 501 | xhrRequestHeaders[key] = val; 502 | } 503 | 504 | static mapContentType(contentType: string): string { 505 | if (contentType === 'json') { 506 | return 'application/json'; 507 | } else if (contentType === 'form') { 508 | return 'application/x-www-form-urlencoded'; 509 | } else { 510 | return contentType; 511 | } 512 | } 513 | 514 | static mapBody(sendData: SendDataType, contentType: string): SendDataType { 515 | if (isJsonContentType(contentType)) { 516 | if (!isString(sendData)) { 517 | return JSON.stringify(sendData); 518 | } 519 | } else if (isFormContentType(contentType)) { 520 | if (!isString(sendData) && isObject(sendData)) { 521 | const params = sendData as Params; 522 | 523 | return Object.keys(params) 524 | .map(param => encodeURIComponent(param) + (params[param] ? '=' + encodeURIComponent(params[param].toString()) : '')) 525 | .join('&'); 526 | } 527 | } else if (isFormDataContentType(contentType)) { 528 | if (isObject(sendData)) { 529 | // Note: This only works for IE10 and above. 530 | const formData = new FormData(); 531 | const params = sendData as Params; 532 | 533 | Object.keys(params) 534 | .forEach(param => formData.append(param, params[param])); 535 | 536 | return formData; 537 | } else { 538 | assert(false, 'contentType multipart/form-data must include an object as sendData'); 539 | } 540 | } 541 | 542 | return sendData; 543 | } 544 | 545 | setUrl(newUrl: string): void { 546 | this._url = newUrl; 547 | } 548 | 549 | setHeader(key: string, val: string|undefined): void { 550 | if (!this._options.augmentHeaders) { 551 | this._options.augmentHeaders = {}; 552 | } 553 | 554 | if (val) { 555 | this._options.augmentHeaders[key] = val; 556 | } else { 557 | delete this._options.augmentHeaders[key]; 558 | } 559 | } 560 | 561 | getRequestHeaders(): Headers { 562 | let headers: Headers = {}; 563 | 564 | if (this._getHeaders && !this._options.overrideGetHeaders && !this._options.headers) { 565 | headers = { ...headers, ...this._getHeaders() }; 566 | } 567 | 568 | if (this._options.overrideGetHeaders) { 569 | headers = { ...headers, ...this._options.overrideGetHeaders }; 570 | } 571 | 572 | if (this._options.headers) { 573 | headers = { ...headers, ...this._options.headers }; 574 | } 575 | 576 | if (this._options.augmentHeaders) { 577 | headers = { ...headers, ...this._options.augmentHeaders }; 578 | } 579 | 580 | return headers; 581 | } 582 | 583 | getOptions(): Readonly { 584 | return clone(this._options); 585 | } 586 | 587 | setPriority(newPriority: WebRequestPriority): void { 588 | if (this._options.priority === newPriority) { 589 | return; 590 | } 591 | 592 | this._options.priority = newPriority; 593 | 594 | if (this._paused) { 595 | return; 596 | } 597 | 598 | if (this._xhr) { 599 | // Already fired -- wait for it to retry for the new priority to matter 600 | return; 601 | } 602 | 603 | // Remove and re-queue 604 | remove(requestQueue, this); 605 | this._enqueue(); 606 | } 607 | 608 | resumeRetrying(): void { 609 | if (!this._paused) { 610 | assert(false, 'resumeRetrying() called but not paused!'); 611 | return; 612 | } 613 | 614 | this._paused = false; 615 | this._enqueue(); 616 | } 617 | 618 | protected _enqueue(): void { 619 | // It's possible for a request to be canceled before it's queued since onCancel fires synchronously and we set up the listener 620 | // before queueing for execution 621 | // An aborted request should never be queued for execution 622 | if (this._aborted) { 623 | return; 624 | } 625 | 626 | // Check if the current queues, if the request is already in there, nothing to enqueue 627 | if (executingList.indexOf(this) >= 0 || blockedList.indexOf(this) >= 0 || requestQueue.indexOf(this) >= 0) { 628 | return; 629 | } 630 | 631 | // Throw it on the queue 632 | const index = requestQueue.findIndex(request => 633 | // find a request with the same priority, but newer 634 | (request.getPriority() === this.getPriority() && request._created > this._created) || 635 | // or a request with lower priority 636 | (request.getPriority() < this.getPriority()), 637 | ); 638 | 639 | if (index > -1) { 640 | // add me before the found request 641 | requestQueue.splice(index, 0, this); 642 | } else { 643 | // add me at the end 644 | requestQueue.push(this); 645 | } 646 | 647 | // See if it's time to execute it 648 | SimpleWebRequestBase.checkQueueProcessing(); 649 | } 650 | 651 | private static _getResponseType(acceptType: string): XMLHttpRequestResponseType { 652 | if (acceptType === 'blob') { 653 | return 'arraybuffer'; 654 | } 655 | 656 | if (acceptType === 'text/xml' || acceptType === 'application/xml') { 657 | return 'document'; 658 | } 659 | 660 | if (acceptType === 'text/plain') { 661 | return 'text'; 662 | } 663 | 664 | return 'json'; 665 | } 666 | 667 | protected abstract _respond(errorStatusText?: string): void; 668 | } 669 | 670 | export class SimpleWebRequest extends SimpleWebRequestBase { 671 | private _resolve?: (resp: WebResponse) => void; 672 | private _reject?: (resp?: any) => void; 673 | 674 | constructor(action: string, 675 | url: string, 676 | options: TOptions, 677 | getHeaders?: () => Headers, 678 | blockRequestUntil?: () => Promise | undefined) { 679 | super(action, url, options, getHeaders, blockRequestUntil); 680 | } 681 | 682 | abort(): void { 683 | if (this._aborted) { 684 | assert(false, 'Already aborted ' + this._action + ' request to ' + this._url); 685 | return; 686 | } 687 | 688 | this._aborted = true; 689 | 690 | if (this._retryTimer) { 691 | SimpleWebRequestOptions.clearTimeout(this._retryTimer); 692 | this._retryTimer = undefined; 693 | } 694 | 695 | if (this._requestTimeoutTimer) { 696 | SimpleWebRequestOptions.clearTimeout(this._requestTimeoutTimer); 697 | this._requestTimeoutTimer = undefined; 698 | } 699 | 700 | if (!this._resolve) { 701 | assert(false, 'Haven\'t even fired start() yet -- can\'t abort'); 702 | return; 703 | } 704 | 705 | // Cannot rely on this._xhr.abort() to trigger this._xhr.onAbort() synchronously, thus we must trigger an early response here 706 | this._respond('Aborted'); 707 | 708 | if (this._xhr) { 709 | // Abort the in-flight request 710 | this._xhr.abort(); 711 | } 712 | } 713 | 714 | start(): Promise> { 715 | if (this._resolve) { 716 | assert(false, 'WebRequest already started'); 717 | return Promise.reject('WebRequest already started'); 718 | } 719 | 720 | const promise = new Promise>((res, rej) => { 721 | this._resolve = res; 722 | this._reject = rej; 723 | }); 724 | 725 | this._enqueue(); 726 | 727 | return promise; 728 | } 729 | 730 | protected _respond(errorStatusText?: string): void { 731 | if (this._finishHandled) { 732 | // Aborted web requests often double-finish due to odd browser behavior, but non-aborted requests shouldn't... 733 | // Unfortunately, this assertion fires frequently in the Safari browser, presumably due to a non-standard 734 | // XHR implementation, so we need to comment it out. 735 | // This also might get hit during browser feature detection process 736 | // assert(this._aborted || this._timedOut, 'Double-finished XMLHttpRequest'); 737 | return; 738 | } 739 | 740 | this._finishHandled = true; 741 | 742 | this._removeFromQueue(); 743 | 744 | if (this._retryTimer) { 745 | SimpleWebRequestOptions.clearTimeout(this._retryTimer); 746 | this._retryTimer = undefined; 747 | } 748 | 749 | if (this._requestTimeoutTimer) { 750 | SimpleWebRequestOptions.clearTimeout(this._requestTimeoutTimer); 751 | this._requestTimeoutTimer = undefined; 752 | } 753 | 754 | let statusCode = 0; 755 | let statusText: string|undefined; 756 | if (this._xhr) { 757 | try { 758 | statusCode = this._xhr.status; 759 | statusText = this._xhr.statusText || errorStatusText; 760 | } catch (e) { 761 | // Some browsers error when you try to read status off aborted requests 762 | } 763 | } else { 764 | statusText = errorStatusText || 'Browser Error - Possible CORS or Connectivity Issue'; 765 | } 766 | 767 | let headers: Headers = {}; 768 | let body: any; 769 | let responseParsingException: Error | undefined; 770 | 771 | // Build the response info 772 | if (this._xhr) { 773 | // Parse out headers 774 | const headerLines = (this._xhr.getAllResponseHeaders() || '').split(/\r?\n/); 775 | headerLines.forEach(line => { 776 | if (line.length === 0) { 777 | return; 778 | } 779 | 780 | const index = line.indexOf(':'); 781 | if (index === -1) { 782 | headers[line] = ''; 783 | } else { 784 | headers[line.substr(0, index).toLowerCase()] = line.substr(index + 1).trim(); 785 | } 786 | }); 787 | 788 | // Some browsers apparently don't set the content-type header in some error conditions from getAllResponseHeaders but do return 789 | // it from the normal getResponseHeader. No clue why, but superagent mentions it as well so it's best to just conform. 790 | if (!headers['content-type']) { 791 | const check = this._xhr.getResponseHeader('content-type'); 792 | if (check) { 793 | headers['content-type'] = check; 794 | } 795 | } 796 | 797 | body = this._xhr.response; 798 | if (headers['content-type'] && isJsonContentType(headers['content-type'])) { 799 | if (!body || !isObject(body)) { 800 | // Response can be null if the responseType does not match what the server actually sends 801 | // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType 802 | 803 | // Only access responseText if responseType is "text" or "", otherwise it will throw 804 | // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseText 805 | if ((this._xhr.responseType === 'text' || this._xhr.responseType === '') && this._xhr.responseText) { 806 | try { 807 | body = JSON.parse(this._xhr.responseText); 808 | } catch (ex) { 809 | // If a service returns invalid JSON in a payload, we can end up here - don't crash 810 | // responseParsingException flag will indicate that we got response from the server that was corrupted. 811 | // This will be manifested as null on receipient side and flag can help in understanding the problem. 812 | responseParsingException = ex; 813 | console.warn('Failed to parse XHR JSON response'); 814 | } 815 | } 816 | } 817 | } 818 | } 819 | 820 | if (this._xhr && this._xhr.readyState === 4 && ((statusCode >= 200 && statusCode < 300) || statusCode === 304)) { 821 | // Happy path! 822 | const resp: WebResponse = { 823 | url: this._xhr.responseURL || this._url, 824 | method: this._action, 825 | requestOptions: this._options, 826 | requestHeaders: this._xhrRequestHeaders || {}, 827 | statusCode: statusCode, 828 | statusText: statusText, 829 | headers: headers, 830 | body: body as TBody, 831 | responseParsingException: responseParsingException, 832 | }; 833 | 834 | this._resolve!!!(resp); 835 | } else { 836 | let errResp: WebErrorResponse = { 837 | url: (this._xhr ? this._xhr.responseURL : undefined) || this._url, 838 | method: this._action, 839 | requestOptions: this._options, 840 | requestHeaders: this._xhrRequestHeaders || {}, 841 | statusCode: statusCode, 842 | statusText: statusText, 843 | headers: headers, 844 | body: body, 845 | canceled: this._aborted, 846 | timedOut: this._timedOut, 847 | responseParsingException: responseParsingException, 848 | }; 849 | 850 | if (this._options.augmentErrorResponse) { 851 | this._options.augmentErrorResponse(errResp); 852 | } 853 | 854 | // Policy-adaptable failure 855 | const handleResponse = this._options.customErrorHandler 856 | ? this._options.customErrorHandler(this, errResp) 857 | : DefaultErrorHandler(this, errResp); 858 | 859 | const retry = handleResponse !== ErrorHandlingType.DoNotRetry && ( 860 | (this._options.retries && this._options.retries > 0) || 861 | handleResponse === ErrorHandlingType.PauseUntilResumed || 862 | handleResponse === ErrorHandlingType.RetryUncountedImmediately || 863 | handleResponse === ErrorHandlingType.RetryUncountedWithBackoff); 864 | 865 | if (retry) { 866 | if (handleResponse === ErrorHandlingType.RetryCountedWithBackoff) { 867 | this._options.retries!!!--; 868 | } 869 | 870 | if (this._requestTimeoutTimer) { 871 | SimpleWebRequestOptions.clearTimeout(this._requestTimeoutTimer); 872 | this._requestTimeoutTimer = undefined; 873 | } 874 | 875 | this._aborted = false; 876 | this._timedOut = false; 877 | this._finishHandled = false; 878 | 879 | // Clear the XHR since we technically just haven't started again yet... 880 | if (this._xhr) { 881 | this._xhr.onabort = null; 882 | this._xhr.onerror = null; 883 | this._xhr.onload = null; 884 | this._xhr.onprogress = null; 885 | this._xhr.onreadystatechange = null; 886 | this._xhr.ontimeout = null; 887 | this._xhr = undefined; 888 | 889 | this._xhrRequestHeaders = undefined; 890 | } 891 | 892 | if (handleResponse === ErrorHandlingType.PauseUntilResumed) { 893 | this._paused = true; 894 | } else if (handleResponse === ErrorHandlingType.RetryUncountedImmediately) { 895 | this._enqueue(); 896 | } else { 897 | this._retryTimer = SimpleWebRequestOptions.setTimeout(() => { 898 | this._retryTimer = undefined; 899 | this._enqueue(); 900 | }, this._retryExponentialTime.getTimeAndCalculateNext()); 901 | } 902 | } else { 903 | // No more retries -- fail. 904 | this._reject!!!(errResp); 905 | } 906 | } 907 | 908 | // Freed up a spot, so let's see if there's other stuff pending 909 | SimpleWebRequestBase.checkQueueProcessing(); 910 | } 911 | } 912 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * utils.ts 3 | * Copyright: Microsoft 2019 4 | */ 5 | 6 | export const assert = (cond: boolean, message: string): void => { 7 | if (!cond) { 8 | throw new Error(message); 9 | } 10 | }; 11 | 12 | 13 | export const isObject = (value: any): value is object => ( 14 | value !== null && typeof value === 'object' 15 | ); 16 | 17 | export const isString = (value: any): value is string => ( 18 | typeof value === 'string' 19 | ); 20 | 21 | export const attempt = (func: (...args: any[]) => T, ...args: any[]): T | Error => { 22 | try { 23 | return func(...args); 24 | } catch (e) { 25 | return new Error(e); 26 | } 27 | }; 28 | 29 | export const remove = (array: T[], value: any): void => { 30 | for (let i = array.length - 1; i >= 0; i--) { 31 | if (array[i] === value) { 32 | array.splice(i, 1); 33 | } 34 | } 35 | }; 36 | 37 | export const clone = (value: T): T => { 38 | if (Array.isArray(value)) { 39 | return value.map(clone) as any; 40 | } 41 | 42 | if (isObject(value)) { 43 | return Object.keys(value) 44 | .reduce((res, key) => ({ ...res, [key]: clone(value[key as keyof T]) }), { } as any); 45 | } 46 | 47 | return value; 48 | }; 49 | -------------------------------------------------------------------------------- /test/ExponentialTime.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExponentialTime, DEFAULT_TIME_JITTER, DEFAULT_TIME_GROW_FACTOR } from '../src/ExponentialTime'; 2 | 3 | const LOW_JITTER_FACTOR = 0.000001; 4 | const RANDOM_VALUE = 0.9524610209591828; 5 | const INITIAL_TIME = 50; 6 | const GROW_FACTOR = 0.1; 7 | const MAX_TIME = 2000; 8 | 9 | const normilizeTimeRange = (time: number): number => { 10 | if (time < INITIAL_TIME) { 11 | return INITIAL_TIME; 12 | } 13 | 14 | if (time > MAX_TIME) { 15 | return MAX_TIME; 16 | } 17 | 18 | return time; 19 | }; 20 | 21 | const calculateExponentialTime = (currentTime: number, growFactor: number, jitterFactor: number): number => { 22 | const delay = currentTime * growFactor; 23 | return normilizeTimeRange(Math.round(RANDOM_VALUE * delay * jitterFactor + delay)); 24 | }; 25 | 26 | const calculateDefaultExponentialTime = (initialTime: number, jitterFactor: number): number => ( 27 | Math.round(initialTime * (1 + RANDOM_VALUE * jitterFactor)) 28 | ); 29 | 30 | describe('ExponentialTime', () => { 31 | beforeAll(() => { 32 | spyOn(Math, 'random').and.returnValue(RANDOM_VALUE); 33 | }); 34 | 35 | it('calculates time with default grow and jitter factor', () => { 36 | const exponentialTime = new ExponentialTime(INITIAL_TIME, MAX_TIME); 37 | 38 | let currentTime = calculateDefaultExponentialTime(INITIAL_TIME, DEFAULT_TIME_JITTER); 39 | expect(exponentialTime.getTime()).toEqual(currentTime); 40 | expect(exponentialTime.getIncrementCount()).toEqual(0); 41 | 42 | currentTime = exponentialTime.getTime(); 43 | exponentialTime.calculateNext(); 44 | expect(exponentialTime.getTime()).toEqual(calculateExponentialTime(currentTime, DEFAULT_TIME_GROW_FACTOR, DEFAULT_TIME_JITTER)); 45 | expect(exponentialTime.getIncrementCount()).toEqual(1); 46 | 47 | currentTime = exponentialTime.getTime(); 48 | exponentialTime.calculateNext(); 49 | expect(exponentialTime.getTime()).toEqual(calculateExponentialTime(currentTime, DEFAULT_TIME_GROW_FACTOR, DEFAULT_TIME_JITTER)); 50 | expect(exponentialTime.getIncrementCount()).toEqual(2); 51 | }); 52 | 53 | it('calculates time with low jitter factor', () => { 54 | const exponentialTime = new ExponentialTime(INITIAL_TIME, MAX_TIME, GROW_FACTOR, LOW_JITTER_FACTOR); 55 | 56 | expect(exponentialTime.getTime()).toEqual(calculateDefaultExponentialTime(INITIAL_TIME, LOW_JITTER_FACTOR)); 57 | expect(exponentialTime.getIncrementCount()).toEqual(0); 58 | 59 | exponentialTime.calculateNext(); 60 | expect(exponentialTime.getTime()).toEqual(normilizeTimeRange(exponentialTime.getTime() * GROW_FACTOR)); 61 | expect(exponentialTime.getIncrementCount()).toEqual(1); 62 | 63 | exponentialTime.calculateNext(); 64 | expect(exponentialTime.getTime()).toEqual(normilizeTimeRange(exponentialTime.getTime() * GROW_FACTOR)); 65 | expect(exponentialTime.getIncrementCount()).toEqual(2); 66 | }); 67 | 68 | it('calculates next time and returns previous time', () => { 69 | const exponentialTime = new ExponentialTime(INITIAL_TIME, MAX_TIME); 70 | 71 | let currentTime = exponentialTime.getTime(); 72 | expect(currentTime).toEqual(exponentialTime.getTimeAndCalculateNext()); 73 | expect(exponentialTime.getIncrementCount()).toEqual(1); 74 | 75 | currentTime = exponentialTime.getTime(); 76 | expect(currentTime).toEqual(exponentialTime.getTimeAndCalculateNext()); 77 | expect(exponentialTime.getIncrementCount()).toEqual(2); 78 | }); 79 | 80 | it('resets time', () => { 81 | const exponentialTime = new ExponentialTime(INITIAL_TIME, MAX_TIME); 82 | const defaultTime = exponentialTime.getTime(); 83 | 84 | exponentialTime.calculateNext(); 85 | exponentialTime.calculateNext(); 86 | exponentialTime.calculateNext(); 87 | exponentialTime.reset(); 88 | 89 | expect(defaultTime).toEqual(exponentialTime.getTime()); 90 | expect(exponentialTime.getIncrementCount()).toEqual(0); 91 | }); 92 | 93 | it('checks correct initial values', () => { 94 | expect(() => new ExponentialTime(0, 0)).toThrowError('Initial delay must be positive'); 95 | expect(() => new ExponentialTime(INITIAL_TIME, 0)).toThrowError('Delay upper bound must be positive'); 96 | 97 | expect(() => new ExponentialTime(INITIAL_TIME, MAX_TIME, -1)) 98 | .toThrowError('Ratio must be non-negative'); 99 | expect(() => new ExponentialTime(INITIAL_TIME, MAX_TIME, DEFAULT_TIME_JITTER, -1)) 100 | .toThrowError('Jitter factor must be non-negative'); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/GenericRestClient.spec.ts: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker'; 2 | 3 | import { ErrorHandlingType, SimpleWebRequestBase, WebErrorResponse } from '../src/SimpleWebRequest'; 4 | import { GenericRestClient, ApiCallOptions } from '../src/GenericRestClient'; 5 | 6 | import { DETAILED_RESPONSE, REQUEST_OPTIONS, asyncTick } from './helpers'; 7 | 8 | class RestClient extends GenericRestClient { } 9 | const BASE_URL = faker.internet.url(); 10 | const http = new RestClient(BASE_URL); 11 | 12 | describe('GenericRestClient', () => { 13 | beforeAll(() => { 14 | jasmine.Ajax.install(); 15 | jasmine.clock().install(); 16 | // Run an initial request to finish feature detection - this is needed so we can directly call onLoad 17 | const statusCode = 200; 18 | const onSuccess = jasmine.createSpy('onSuccess'); 19 | const path = '/auth'; 20 | 21 | http.performApiGet(path) 22 | .then(onSuccess); 23 | 24 | return asyncTick().then(() => { 25 | const request = jasmine.Ajax.requests.mostRecent(); 26 | request.respondWith({ status: statusCode }); 27 | return asyncTick(); 28 | }).then(() => { 29 | expect(onSuccess).toHaveBeenCalled(); 30 | jasmine.Ajax.uninstall(); 31 | jasmine.clock().uninstall(); 32 | }); 33 | }); 34 | 35 | beforeEach(() => { 36 | jasmine.Ajax.install(); 37 | jasmine.clock().install(); 38 | }); 39 | afterEach(() => { 40 | jasmine.Ajax.uninstall(); 41 | jasmine.clock().uninstall(); 42 | }); 43 | 44 | it('performs GET request with performApiGet ', () => { 45 | const id = faker.random.uuid(); 46 | const statusCode = 200; 47 | const onSuccess = jasmine.createSpy('onSuccess'); 48 | const method = 'GET'; 49 | const body = { 50 | title: faker.name.title(), 51 | text: faker.lorem.text(), 52 | id, 53 | }; 54 | const path = `/get/${id}`; 55 | const url = BASE_URL + path; 56 | 57 | const p1 = http.performApiGet(path) 58 | .then(onSuccess); 59 | 60 | return asyncTick().then(() => { 61 | const request = jasmine.Ajax.requests.mostRecent(); 62 | expect(request.url).toEqual(url); 63 | expect(request.method).toEqual(method); 64 | request.respondWith({ 65 | responseText: JSON.stringify(body), 66 | status: statusCode, 67 | }); 68 | expect(request.status).toEqual(statusCode); 69 | return p1; 70 | }).then(() => { 71 | expect(onSuccess).toHaveBeenCalledWith(body); 72 | }); 73 | }); 74 | 75 | it('performs GET request with performApiGetDetailed', () => { 76 | const id = faker.random.uuid(); 77 | const statusCode = 200; 78 | const onSuccess = jasmine.createSpy('onSuccess'); 79 | const method = 'GET'; 80 | const body = { 81 | title: faker.name.title(), 82 | text: faker.lorem.text(), 83 | id, 84 | }; 85 | const path = `/get/${id}`; 86 | const url = BASE_URL + path; 87 | const responseParsingException = undefined; 88 | const response = { 89 | ...DETAILED_RESPONSE, 90 | requestHeaders: { 'Accept': 'application/json' }, 91 | statusCode, 92 | method, 93 | body, 94 | url, 95 | responseParsingException, 96 | }; 97 | 98 | const p1 = http.performApiGetDetailed(path, { contentType: 'json' }) 99 | .promise.then(onSuccess); 100 | 101 | return asyncTick().then(() => { 102 | const request = jasmine.Ajax.requests.mostRecent(); 103 | expect(request.url).toEqual(url); 104 | expect(request.method).toEqual(method); 105 | 106 | request.respondWith({ 107 | responseText: JSON.stringify(body), 108 | status: statusCode, 109 | }); 110 | return p1; 111 | }).then(() => { 112 | expect(onSuccess).toHaveBeenCalledWith(response); 113 | }); 114 | }); 115 | 116 | it('performs POST request with performApiPost ', () => { 117 | const statusCode = 201; 118 | const onSuccess = jasmine.createSpy('onSuccess'); 119 | const method = 'POST'; 120 | const sendData = { 121 | title: faker.name.title(), 122 | text: faker.lorem.text(), 123 | }; 124 | const body = { ...sendData, id: faker.random.uuid() }; 125 | const path = '/post'; 126 | const url = BASE_URL + path; 127 | 128 | const p1 = http.performApiPost(path, sendData, { contentType: 'json' }) 129 | .then(onSuccess); 130 | 131 | return asyncTick().then(() => { 132 | const request = jasmine.Ajax.requests.mostRecent(); 133 | expect(request.url).toEqual(url); 134 | expect(request.method).toEqual(method); 135 | 136 | request.respondWith({ 137 | responseText: JSON.stringify(body), 138 | status: statusCode, 139 | }); 140 | expect(request.status).toEqual(statusCode); 141 | expect(request.data() as any).toEqual(sendData); 142 | return p1; 143 | }).then(() => { 144 | expect(onSuccess).toHaveBeenCalledWith(body); 145 | }); 146 | }); 147 | 148 | it('performs POST request with performApiPostDetailed', () => { 149 | const statusCode = 201; 150 | const onSuccess = jasmine.createSpy('onSuccess'); 151 | const sendData = { 152 | title: faker.name.title(), 153 | text: faker.lorem.text(), 154 | }; 155 | const method = 'POST'; 156 | const body = { ...sendData, id: faker.random.uuid() }; 157 | const path = '/post'; 158 | const url = BASE_URL + path; 159 | const responseParsingException = undefined; 160 | const response = { 161 | ...DETAILED_RESPONSE, 162 | requestOptions: { ...REQUEST_OPTIONS, sendData }, 163 | statusCode, 164 | method, 165 | body, 166 | url, 167 | responseParsingException, 168 | }; 169 | 170 | const p1 = http.performApiPostDetailed(path, sendData, { contentType: 'json' }) 171 | .promise.then(onSuccess); 172 | 173 | return asyncTick().then(() => { 174 | const request = jasmine.Ajax.requests.mostRecent(); 175 | expect(request.url).toEqual(url); 176 | expect(request.method).toEqual(method); 177 | 178 | request.respondWith({ 179 | responseText: JSON.stringify(body), 180 | status: statusCode, 181 | }); 182 | expect(request.status).toEqual(statusCode); 183 | return p1; 184 | }).then(() => { 185 | expect(onSuccess).toHaveBeenCalledWith(response); 186 | }); 187 | }); 188 | 189 | it('performs PUT request with performApiPut', () => { 190 | const id = faker.random.uuid(); 191 | const statusCode = 200; 192 | const onSuccess = jasmine.createSpy('onSuccess'); 193 | const sendData = { title: faker.name.title() }; 194 | const method = 'PUT'; 195 | const body = { ...sendData, id }; 196 | const path = '/put/' + id; 197 | const url = BASE_URL + path; 198 | 199 | const p1 = http.performApiPut(path, sendData, { contentType: 'json' }) 200 | .then(onSuccess); 201 | 202 | return asyncTick().then(() => { 203 | const request = jasmine.Ajax.requests.mostRecent(); 204 | expect(request.url).toEqual(url); 205 | expect(request.method).toEqual(method); 206 | 207 | request.respondWith({ 208 | responseText: JSON.stringify(body), 209 | status: statusCode, 210 | }); 211 | expect(request.status).toEqual(statusCode); 212 | expect(request.data() as any).toEqual(sendData); 213 | return p1; 214 | }).then(() => { 215 | expect(onSuccess).toHaveBeenCalledWith(body); 216 | }); 217 | }); 218 | 219 | it('performs PUT request with performApiPutDetailed', () => { 220 | const id = faker.random.uuid(); 221 | const statusCode = 200; 222 | const onSuccess = jasmine.createSpy('onSuccess'); 223 | const sendData = { title: faker.name.title() }; 224 | const method = 'PUT'; 225 | const body = { ...sendData, id: faker.random.uuid() }; 226 | const path = `/patch/${id}`; 227 | const url = BASE_URL + path; 228 | const responseParsingException = undefined; 229 | const response = { 230 | ...DETAILED_RESPONSE, 231 | requestOptions: { ...REQUEST_OPTIONS, sendData }, 232 | statusCode, 233 | method, 234 | body, 235 | url, 236 | responseParsingException, 237 | }; 238 | 239 | const p1 = http.performApiPutDetailed(path, sendData, { contentType: 'json' }) 240 | .promise.then(onSuccess); 241 | 242 | return asyncTick().then(() => { 243 | const request = jasmine.Ajax.requests.mostRecent(); 244 | expect(request.url).toEqual(url); 245 | expect(request.method).toEqual(method); 246 | 247 | request.respondWith({ 248 | responseText: JSON.stringify(body), 249 | status: statusCode, 250 | }); 251 | expect(request.status).toEqual(statusCode); 252 | expect(request.data() as any).toEqual(sendData); 253 | return p1; 254 | }).then(() => { 255 | expect(onSuccess).toHaveBeenCalledWith(response); 256 | }); 257 | }); 258 | 259 | it('performs PATCH request with performApiPatch', () => { 260 | const id = faker.random.uuid(); 261 | const statusCode = 200; 262 | const onSuccess = jasmine.createSpy('onSuccess'); 263 | const method = 'PATCH'; 264 | const sendData = { 265 | title: faker.name.title(), 266 | text: faker.lorem.text(), 267 | }; 268 | const body = { ...sendData, text: faker.lorem.text(), id }; 269 | const path = '/patch' + id; 270 | const url = BASE_URL + path; 271 | 272 | const p1 = http.performApiPatch(path, sendData, { contentType: 'json' }) 273 | .then(onSuccess); 274 | 275 | return asyncTick().then(() => { 276 | const request = jasmine.Ajax.requests.mostRecent(); 277 | expect(request.url).toEqual(url); 278 | expect(request.method).toEqual(method); 279 | 280 | request.respondWith({ 281 | responseText: JSON.stringify(body), 282 | status: statusCode, 283 | }); 284 | expect(request.status).toEqual(statusCode); 285 | expect(request.data() as any).toEqual(sendData); 286 | return p1; 287 | }).then(() => { 288 | expect(onSuccess).toHaveBeenCalledWith(body); 289 | }); 290 | }); 291 | 292 | it('performs PATCH request with performApiPatchDetailed', () => { 293 | const id = faker.random.uuid(); 294 | const statusCode = 200; 295 | const onSuccess = jasmine.createSpy('onSuccess'); 296 | const sendData = { 297 | title: faker.name.title(), 298 | text: faker.lorem.text(), 299 | }; 300 | const method = 'PATCH'; 301 | const body = { ...sendData, id: faker.random.uuid() }; 302 | const path = `/patch/${id}`; 303 | const url = BASE_URL + path; 304 | const responseParsingException = undefined; 305 | const response = { 306 | ...DETAILED_RESPONSE, 307 | requestOptions: { ...REQUEST_OPTIONS, sendData }, 308 | statusCode, 309 | method, 310 | body, 311 | url, 312 | responseParsingException, 313 | }; 314 | 315 | const p1 = http.performApiPatchDetailed(path, sendData, { contentType: 'json' }) 316 | .promise.then(onSuccess); 317 | 318 | return asyncTick().then(() => { 319 | const request = jasmine.Ajax.requests.mostRecent(); 320 | expect(request.url).toEqual(url); 321 | expect(request.method).toEqual(method); 322 | 323 | request.respondWith({ 324 | responseText: JSON.stringify(body), 325 | status: statusCode, 326 | }); 327 | expect(request.status).toEqual(statusCode); 328 | expect(request.data() as any).toEqual(sendData); 329 | return p1; 330 | }).then(() => { 331 | expect(onSuccess).toHaveBeenCalledWith(response); 332 | }); 333 | }); 334 | 335 | it('performs DELETE request with performApiDelete', () => { 336 | const statusCode = 200; 337 | const onSuccess = jasmine.createSpy('onSuccess'); 338 | const method = 'DELETE'; 339 | const body = {}; 340 | const path = `/delete/${faker.random.uuid()}`; 341 | const url = BASE_URL + path; 342 | 343 | const p1 = http.performApiDelete(path) 344 | .then(onSuccess); 345 | 346 | return asyncTick().then(() => { 347 | const request = jasmine.Ajax.requests.mostRecent(); 348 | expect(request.url).toEqual(url); 349 | expect(request.method).toEqual(method); 350 | request.respondWith({ 351 | responseText: JSON.stringify(body), 352 | status: statusCode, 353 | }); 354 | return p1; 355 | }).then(() => { 356 | expect(onSuccess).toHaveBeenCalledWith(body); 357 | }); 358 | }); 359 | 360 | it('performs DELETE request with performApiDeleteDetailed', () => { 361 | const id = faker.random.uuid(); 362 | const statusCode = 200; 363 | const onSuccess = jasmine.createSpy('onSuccess'); 364 | const sendData = { id }; 365 | const method = 'DELETE'; 366 | const body = {}; 367 | const path = `/delete/${id}`; 368 | const url = BASE_URL + path; 369 | const responseParsingException = undefined; 370 | const response = { 371 | ...DETAILED_RESPONSE, 372 | requestOptions: { ...REQUEST_OPTIONS, sendData }, 373 | statusCode, 374 | method, 375 | body, 376 | url, 377 | responseParsingException, 378 | }; 379 | 380 | const p1 = http.performApiDeleteDetailed(path, sendData, { contentType: 'json' }) 381 | .promise.then(onSuccess); 382 | 383 | return asyncTick().then(() => { 384 | const request = jasmine.Ajax.requests.mostRecent(); 385 | expect(request.url).toEqual(url); 386 | expect(request.method).toEqual(method); 387 | 388 | request.respondWith({ 389 | responseText: JSON.stringify(body), 390 | status: statusCode, 391 | }); 392 | expect(request.data() as any).toEqual(sendData); 393 | return p1; 394 | }).then(() => { 395 | expect(onSuccess).toHaveBeenCalledWith(response); 396 | }); 397 | }); 398 | 399 | it('performs request with custom headers', () => { 400 | const headers = { 401 | 'Authorization': `Barrier ${faker.random.uuid()}`, 402 | }; 403 | 404 | class Http extends GenericRestClient { 405 | protected _getHeaders(options: ApiCallOptions): { [header: string]: string } { 406 | return headers; 407 | } 408 | } 409 | const statusCode = 200; 410 | const onSuccess = jasmine.createSpy('onSuccess'); 411 | const http = new Http(BASE_URL); 412 | const path = '/auth'; 413 | 414 | const p1 = http.performApiGet(path) 415 | .then(onSuccess); 416 | 417 | return asyncTick().then(() => { 418 | const request = jasmine.Ajax.requests.mostRecent(); 419 | expect(request.requestHeaders['Authorization']).toEqual(headers['Authorization']); 420 | request.respondWith({ status: statusCode }); 421 | return p1; 422 | }).then(() => { 423 | expect(onSuccess).toHaveBeenCalled(); 424 | }); 425 | }); 426 | 427 | it('overrides response', () => { 428 | class Http extends GenericRestClient { 429 | protected _processSuccessResponse(resp: any): void { 430 | resp.body = resp.body.map((str: string) => str.trim()); 431 | } 432 | } 433 | const statusCode = 200; 434 | const onSuccess = jasmine.createSpy('onSuccess'); 435 | const method = 'GET'; 436 | const http = new Http(BASE_URL); 437 | const path = '/get'; 438 | const body = [' x ', ' y ', ' z ']; 439 | const url = BASE_URL + path; 440 | 441 | const p1 = http.performApiGet(path) 442 | .then(onSuccess); 443 | 444 | return asyncTick().then(() => { 445 | const request = jasmine.Ajax.requests.mostRecent(); 446 | expect(request.url).toEqual(url); 447 | expect(request.method).toEqual(method); 448 | 449 | request.respondWith({ 450 | responseText: JSON.stringify(body), 451 | status: statusCode, 452 | }); 453 | expect(request.status).toEqual(statusCode); 454 | 455 | return p1; 456 | }).then(() => { 457 | expect(onSuccess).toHaveBeenCalledWith(body.map((str: string) => str.trim())); 458 | }); 459 | }); 460 | 461 | it('blocks the request with custom method', () => { 462 | let blockResolver: () => void = () => undefined; 463 | const blockPromise = new Promise((res, rej) => { blockResolver = res; }); 464 | 465 | class Http extends GenericRestClient { 466 | protected _blockRequestUntil(): Promise { 467 | return blockPromise; 468 | } 469 | } 470 | 471 | const statusCode = 200; 472 | const onSuccess = jasmine.createSpy('onSuccess'); 473 | const http = new Http(BASE_URL); 474 | const path = '/auth'; 475 | 476 | const p1 = http.performApiGet(path) 477 | .then(onSuccess); 478 | 479 | let request: any; 480 | return asyncTick().then(() => { 481 | request = jasmine.Ajax.requests.mostRecent(); 482 | 483 | expect(request).toBeUndefined(); 484 | blockResolver(); 485 | 486 | return asyncTick(); 487 | }).then(() => { 488 | request = jasmine.Ajax.requests.mostRecent(); 489 | request.respondWith({ status: statusCode }); 490 | return p1; 491 | }).then(() => { 492 | expect(onSuccess).toHaveBeenCalled(); 493 | }); 494 | }); 495 | 496 | it('aborting request after failure w/retry', () => { 497 | let blockResolver: () => void = () => undefined; 498 | let blockPromise = new Promise((res, rej) => { blockResolver = res; }); 499 | 500 | class Http extends GenericRestClient { 501 | constructor(endpointUrl: string) { 502 | super(endpointUrl); 503 | this._defaultOptions.customErrorHandler = this._customErrorHandler; 504 | this._defaultOptions.timeout = 1; 505 | } 506 | protected _blockRequestUntil(): Promise { 507 | return blockPromise; 508 | } 509 | 510 | protected _customErrorHandler = (webRequest: SimpleWebRequestBase, errorResponse: WebErrorResponse): ErrorHandlingType => { 511 | if (errorResponse.canceled) { 512 | return ErrorHandlingType.DoNotRetry; 513 | } 514 | return ErrorHandlingType.RetryUncountedImmediately; 515 | }; 516 | } 517 | 518 | const statusCode = 400; 519 | const onSuccess = jasmine.createSpy('onSuccess'); 520 | const onFailure = jasmine.createSpy('onFailure'); 521 | const http = new Http(BASE_URL); 522 | const path = '/auth'; 523 | 524 | const resp = http.performApiGetDetailed(path); 525 | const p1 = resp.promise 526 | .then(onSuccess) 527 | .catch(onFailure); 528 | 529 | return asyncTick().then(() => { 530 | blockResolver(); 531 | return asyncTick(); 532 | }).then(() => { 533 | const request1 = jasmine.Ajax.requests.mostRecent(); 534 | 535 | // Reset blockuntil so retries may block 536 | blockPromise = new Promise((res, rej) => { blockResolver = res; }); 537 | 538 | request1.respondWith({ status: statusCode }); 539 | return asyncTick(); 540 | }).then(() => { 541 | expect(onSuccess).not.toHaveBeenCalled(); 542 | expect(onFailure).not.toHaveBeenCalled(); 543 | 544 | resp.req.abort(); 545 | return p1; 546 | }).then(() => { 547 | expect(onSuccess).not.toHaveBeenCalled(); 548 | expect(onFailure).toHaveBeenCalled(); 549 | }); 550 | }); 551 | 552 | describe('Timing related tests' , () => { 553 | it('failed request with retry handles multiple _respond calls', () => { 554 | let blockResolver: () => void = () => undefined; 555 | let blockPromise = new Promise((res, rej) => { blockResolver = res; }); 556 | 557 | class Http extends GenericRestClient { 558 | constructor(endpointUrl: string) { 559 | super(endpointUrl); 560 | this._defaultOptions.customErrorHandler = this._customErrorHandler; 561 | this._defaultOptions.timeout = 1; 562 | } 563 | protected _blockRequestUntil(): Promise { 564 | return blockPromise; 565 | } 566 | 567 | protected _customErrorHandler = (): ErrorHandlingType => { 568 | return ErrorHandlingType.RetryUncountedImmediately; 569 | }; 570 | } 571 | 572 | const statusCode = 400; 573 | const onSuccess = jasmine.createSpy('onSuccess'); 574 | const http = new Http(BASE_URL); 575 | const path = '/auth'; 576 | 577 | const p1 = http.performApiGet(path) 578 | .then(onSuccess); 579 | 580 | return asyncTick().then(() => { 581 | blockResolver(); 582 | return asyncTick(); 583 | }).then(() => { 584 | const request1 = jasmine.Ajax.requests.mostRecent(); 585 | 586 | // Reset blockuntil so retries may block 587 | blockPromise = new Promise((res, rej) => { blockResolver = res; }); 588 | 589 | // Store this so we're able to emulate double-request callbacks 590 | const onloadToCall = request1.onload as any; 591 | request1.respondWith({ status: statusCode }); 592 | onloadToCall(undefined); 593 | 594 | return asyncTick(); 595 | }).then(() => { 596 | expect(onSuccess).not.toHaveBeenCalled(); 597 | blockResolver(); 598 | 599 | jasmine.clock().tick(100); 600 | return asyncTick(); 601 | }).then(() => { 602 | const request2 = jasmine.Ajax.requests.mostRecent(); 603 | request2.respondWith({ status: 200 }); 604 | return p1; 605 | }).then(() => { 606 | expect(onSuccess).toHaveBeenCalled(); 607 | }); 608 | }); 609 | }); 610 | }); 611 | -------------------------------------------------------------------------------- /test/SimpleWebRequest.spec.ts: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker'; 2 | 3 | import { 4 | ErrorHandlingType, 5 | SimpleWebRequest, 6 | SimpleWebRequestOptions, 7 | WebErrorResponse, 8 | WebRequestPriority, 9 | } from '../src/SimpleWebRequest'; 10 | 11 | import { asyncTick, DETAILED_RESPONSE } from './helpers'; 12 | 13 | describe('SimpleWebRequest', () => { 14 | beforeEach(() => { 15 | jasmine.Ajax.install(); 16 | }); 17 | afterEach(() => { 18 | jasmine.Ajax.uninstall(); 19 | }); 20 | 21 | it('performs GET request', () => { 22 | const requestOptions = { contentType: 'json' }; 23 | const requestHeaders = { 'Accept': 'application/json' }; 24 | const statusCode = 200; 25 | const onSuccess = jasmine.createSpy('onSuccess'); 26 | const method = 'GET'; 27 | const url = faker.internet.url(); 28 | const responseParsingException = undefined; 29 | const response = { 30 | ...DETAILED_RESPONSE, 31 | requestOptions: { ...requestOptions, priority: WebRequestPriority.Normal }, 32 | requestHeaders, 33 | method, 34 | url, 35 | responseParsingException, 36 | }; 37 | 38 | let request: any; 39 | setTimeout(() => { 40 | request = jasmine.Ajax.requests.mostRecent(); 41 | request.respondWith({ responseText: JSON.stringify(''), status: statusCode }); 42 | }, 0); 43 | 44 | return new SimpleWebRequest(method, url, requestOptions) 45 | .start() 46 | .then(onSuccess) 47 | .then(() => { 48 | expect(request.url).toEqual(url); 49 | expect(request.method).toEqual(method); 50 | expect(request.status).toEqual(statusCode); 51 | expect(onSuccess).toHaveBeenCalledWith(response); 52 | }); 53 | }); 54 | 55 | it('sends json POST request', () => { 56 | const sendData = { 57 | title: faker.name.title(), 58 | text: faker.lorem.text(), 59 | }; 60 | const requestOptions = { sendData }; 61 | const statusCode = 201; 62 | const onSuccess = jasmine.createSpy('onSuccess'); 63 | const method = 'POST'; 64 | const body = { ...sendData, id: faker.random.uuid() }; 65 | const url = faker.internet.url(); 66 | const responseParsingException = undefined; 67 | const response = { 68 | ...DETAILED_RESPONSE, 69 | requestOptions: { ...requestOptions, priority: WebRequestPriority.Normal }, 70 | statusCode, 71 | method, 72 | body, 73 | url, 74 | responseParsingException, 75 | }; 76 | 77 | let request: any; 78 | setTimeout(() => { 79 | request = jasmine.Ajax.requests.mostRecent(); 80 | request.respondWith({ responseText: JSON.stringify(body), status: statusCode }); 81 | }, 0); 82 | 83 | return new SimpleWebRequest(method, url, requestOptions) 84 | .start() 85 | .then(onSuccess) 86 | .then(() => { 87 | expect(request.url).toEqual(url); 88 | expect(request.method).toEqual(method); 89 | expect(request.status).toEqual(statusCode); 90 | expect(onSuccess).toHaveBeenCalledWith(response); 91 | }); 92 | }); 93 | 94 | it('allows to set request headers', () => { 95 | const headers = { 96 | 'X-Requested-With': 'XMLHttpRequest', 97 | 'Max-Forwards': '10', 98 | }; 99 | const method = 'POST'; 100 | const url = faker.internet.url(); 101 | 102 | let request: any; 103 | setTimeout(() => { 104 | request = jasmine.Ajax.requests.mostRecent(); 105 | request.respondWith({ status: 200 }); 106 | }, 0); 107 | 108 | return new SimpleWebRequest(method, url, {}, () => headers).start().then(() => { 109 | expect(request.requestHeaders['X-Requested-With']).toEqual(headers['X-Requested-With']); 110 | expect(request.requestHeaders['Max-Forwards']).toEqual(headers['Max-Forwards']); 111 | }); 112 | }); 113 | 114 | it('forbids to set Accept header', () => { 115 | spyOn(console, 'error'); 116 | 117 | const headers = { 118 | 'Accept': 'application/xml', 119 | }; 120 | const method = 'GET'; 121 | const url = faker.internet.url(); 122 | const error = `Don't set Accept with options.headers -- use it with the options.acceptType property`; 123 | const request = new SimpleWebRequest(method, url, {}, () => headers); 124 | 125 | expect(() => (request as any)._fire()).toThrowError(error); 126 | expect(console.error).toHaveBeenCalledWith(error); 127 | }); 128 | 129 | it('forbids to set Content-Type header', () => { 130 | spyOn(console, 'error'); 131 | 132 | const headers = { 133 | 'Content-Type': 'application/xml', 134 | }; 135 | const method = 'GET'; 136 | const url = faker.internet.url(); 137 | const error = `Don't set Content-Type with options.headers -- use it with the options.contentType property`; 138 | const request = new SimpleWebRequest(method, url, {}, () => headers); 139 | 140 | expect(() => (request as any)._fire()).toThrowError(error); 141 | expect(console.error).toHaveBeenCalledWith(error); 142 | }); 143 | 144 | describe('blocking', () => { 145 | let maxRequests = 0; 146 | 147 | beforeEach(() => { 148 | maxRequests = SimpleWebRequestOptions.MaxSimultaneousRequests; 149 | SimpleWebRequestOptions.MaxSimultaneousRequests = 0; 150 | jasmine.clock().install(); 151 | }); 152 | 153 | afterEach(() => { 154 | SimpleWebRequestOptions.MaxSimultaneousRequests = maxRequests; 155 | jasmine.clock().uninstall(); 156 | }); 157 | 158 | it('executes the requests by priority and age', () => { 159 | const url = faker.internet.url(); 160 | const method = 'GET'; 161 | const onSuccessLow1 = jasmine.createSpy('onSuccessLow1'); 162 | const onSuccessCritical1 = jasmine.createSpy('onSuccessCritical1'); 163 | const onSuccessLow2 = jasmine.createSpy('onSuccessLow2'); 164 | const onSuccessCritical2 = jasmine.createSpy('onSuccessCritical2'); 165 | const status = 200; 166 | 167 | const p1 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.Low }) 168 | .start().then(onSuccessLow1); 169 | jasmine.clock().tick(10); 170 | 171 | const p2 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.Critical }) 172 | .start().then(onSuccessCritical1); 173 | jasmine.clock().tick(10); 174 | 175 | const p3 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.Low }) 176 | .start().then(onSuccessLow2); 177 | jasmine.clock().tick(10); 178 | 179 | SimpleWebRequestOptions.MaxSimultaneousRequests = 1; 180 | // add a new request to kick the queue 181 | const p4 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.Critical }) 182 | .start().then(onSuccessCritical2); 183 | 184 | asyncTick().then(() => { 185 | // only one is executed 186 | expect(jasmine.Ajax.requests.count()).toBe(1); 187 | jasmine.Ajax.requests.mostRecent().respondWith({status}); 188 | }); 189 | 190 | return p2.then(() => { 191 | // they're executed in correct order 192 | expect(onSuccessCritical1).toHaveBeenCalled(); 193 | expect(jasmine.Ajax.requests.count()).toBe(2); 194 | jasmine.Ajax.requests.mostRecent().respondWith({status}); 195 | 196 | return p4; 197 | }).then(() => { 198 | expect(onSuccessCritical2).toHaveBeenCalled(); 199 | expect(jasmine.Ajax.requests.count()).toBe(3); 200 | jasmine.Ajax.requests.mostRecent().respondWith({status}); 201 | 202 | return p1; 203 | }).then(() => { 204 | expect(onSuccessLow1).toHaveBeenCalled(); 205 | expect(jasmine.Ajax.requests.count()).toBe(4); 206 | jasmine.Ajax.requests.mostRecent().respondWith({status}); 207 | 208 | return p3; 209 | }).then(() => { 210 | expect(onSuccessLow2).toHaveBeenCalled(); 211 | }); 212 | }); 213 | 214 | it('blocks the request with custom promise', () => { 215 | SimpleWebRequestOptions.MaxSimultaneousRequests = 1; 216 | const url = faker.internet.url(); 217 | const method = 'GET'; 218 | let blockResolver: () => void = () => undefined; 219 | const blockPromise = new Promise((res, rej) => { blockResolver = res; }); 220 | const onSuccess1 = jasmine.createSpy('onSuccess1'); 221 | const p1 = new SimpleWebRequest(method, url, {}, undefined, () => blockPromise).start().then(onSuccess1); 222 | 223 | asyncTick().then(() => { 224 | expect(jasmine.Ajax.requests.count()).toBe(0); 225 | blockResolver(); 226 | 227 | return asyncTick(); 228 | }).then(() => { 229 | const request = jasmine.Ajax.requests.mostRecent(); 230 | request.respondWith({ status: 200 }); 231 | }); 232 | 233 | return p1.then(() => { 234 | expect(onSuccess1).toHaveBeenCalled(); 235 | }); 236 | }); 237 | 238 | it('after the request is unblocked, it\'s returned to the queue with correct priority', () => { 239 | const url = faker.internet.url(); 240 | const method = 'GET'; 241 | let blockResolver: () => void = () => undefined; 242 | const blockPromise = new Promise((res, rej) => { blockResolver = res; }); 243 | const onSuccessHigh = jasmine.createSpy('onSuccessHigh'); 244 | const onSuccessLow = jasmine.createSpy('onSuccessLow'); 245 | const onSuccessCritical = jasmine.createSpy('onSuccessCritical'); 246 | 247 | const p1 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.High }, undefined, () => blockPromise) 248 | .start() 249 | .then(onSuccessHigh); 250 | jasmine.clock().tick(10); 251 | const p2 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.Low }).start().then(onSuccessLow); 252 | jasmine.clock().tick(10); 253 | 254 | SimpleWebRequestOptions.MaxSimultaneousRequests = 1; 255 | // add a new request to kick the queue 256 | const p3 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.Critical }).start().then(onSuccessCritical); 257 | 258 | // unblock the request 259 | blockResolver(); 260 | 261 | // have to do an awkward async tick to get the blocking blocker to resolve before the request goes out 262 | asyncTick().then(() => { 263 | expect(jasmine.Ajax.requests.count()).toBe(1); 264 | jasmine.Ajax.requests.mostRecent().respondWith({ status: 200 }); 265 | }); 266 | 267 | return p3.then(() => { 268 | // first the critical one gets sent 269 | expect(onSuccessCritical).toHaveBeenCalled(); 270 | expect(jasmine.Ajax.requests.count()).toBe(2); 271 | 272 | // then the high, which was returned to the queue at after getting unblocked 273 | jasmine.Ajax.requests.mostRecent().respondWith({ status: 200 }); 274 | 275 | return p1; 276 | }).then(() => { 277 | expect(onSuccessHigh).toHaveBeenCalled(); 278 | expect(jasmine.Ajax.requests.count()).toBe(3); 279 | 280 | // and the low priority one gets sent last 281 | jasmine.Ajax.requests.mostRecent().respondWith({ status: 200 }); 282 | 283 | return p2; 284 | }).then(() => { 285 | expect(onSuccessLow).toHaveBeenCalled(); 286 | }); 287 | }); 288 | 289 | it('checks the blocked function again, once the request is on top of the queue', () => { 290 | const url = faker.internet.url(); 291 | const method = 'GET'; 292 | let blockResolver: () => void = () => undefined; 293 | const blockPromise = new Promise((res, rej) => { blockResolver = res; }); 294 | const onSuccessCritical = jasmine.createSpy('onSuccessCritical'); 295 | const onSuccessHigh = jasmine.createSpy('onSuccessHigh'); 296 | const onSuccessHigh2 = jasmine.createSpy('onSuccessHigh2'); 297 | const blockSpy = jasmine.createSpy('blockSpy').and.callFake(() => blockPromise); 298 | 299 | const p1 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.Critical }, undefined, blockSpy) 300 | .start() 301 | .then(onSuccessCritical); 302 | jasmine.clock().tick(10); 303 | 304 | const p2 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.High }).start().then(onSuccessHigh); 305 | jasmine.clock().tick(10); 306 | 307 | SimpleWebRequestOptions.MaxSimultaneousRequests = 1; 308 | // add a new request to kick the queue 309 | const p3 = new SimpleWebRequest(method, url, { priority: WebRequestPriority.High }).start().then(onSuccessHigh2); 310 | 311 | asyncTick().then(() => { 312 | expect(blockSpy).toHaveBeenCalled(); 313 | expect(jasmine.Ajax.requests.count()).toBe(1); 314 | jasmine.Ajax.requests.mostRecent().respondWith({ status: 200 }); 315 | }); 316 | 317 | return p2.then(() => { 318 | expect(onSuccessHigh).toHaveBeenCalled(); 319 | expect(jasmine.Ajax.requests.count()).toBe(2); 320 | 321 | // unblock the request, it will go back to the queue after the currently executed request 322 | blockResolver(); 323 | 324 | jasmine.Ajax.requests.mostRecent().respondWith({ status: 200 }); 325 | 326 | return p3; 327 | }).then(() => { 328 | expect(onSuccessHigh2).toHaveBeenCalled(); 329 | expect(jasmine.Ajax.requests.count()).toBe(3); 330 | 331 | jasmine.Ajax.requests.mostRecent().respondWith({ status: 200 }); 332 | 333 | return p1; 334 | }).then(() => { 335 | expect(onSuccessCritical).toHaveBeenCalled(); 336 | }); 337 | }); 338 | 339 | it('fails the request, if the blocking promise rejects', done => { 340 | SimpleWebRequestOptions.MaxSimultaneousRequests = 1; 341 | const url = faker.internet.url(); 342 | const method = 'GET'; 343 | let blockRejecter: (err: any) => void = () => undefined; 344 | const blockPromise = new Promise((res, rej) => { blockRejecter = rej; }); 345 | const errorString = 'Terrible error'; 346 | new SimpleWebRequest(method, url, { priority: WebRequestPriority.Critical }, undefined, () => blockPromise) 347 | .start() 348 | .then(() => fail(), (err: WebErrorResponse) => { 349 | expect(err.statusCode).toBe(0); 350 | expect(err.statusText).toBe('_blockRequestUntil rejected: ' + errorString); 351 | done(); 352 | }); 353 | 354 | blockRejecter(errorString); 355 | }); 356 | 357 | it('does not attempt to fire aborted request, if it was aborted while blocked', () => { 358 | SimpleWebRequestOptions.MaxSimultaneousRequests = 1; 359 | const url = faker.internet.url(); 360 | const method = 'GET'; 361 | let blockResolver: () => void = () => undefined; 362 | const blockPromise = new Promise((res, rej) => { blockResolver = res; }); 363 | const req = new SimpleWebRequest(method, url, { priority: WebRequestPriority.Critical }, undefined, () => blockPromise); 364 | const p1 = req.start(); 365 | 366 | return asyncTick().then(() => { 367 | expect(jasmine.Ajax.requests.count()).toBe(0); 368 | req.abort(); 369 | return asyncTick(); 370 | }).then(() => { 371 | blockResolver(); 372 | return p1; 373 | }).then(() => { 374 | fail(); 375 | }, () => { 376 | expect(jasmine.Ajax.requests.count()).toBe(0); 377 | }); 378 | }); 379 | }); 380 | 381 | describe('retries', () => { 382 | beforeEach(() => { 383 | jasmine.clock().install(); 384 | }); 385 | 386 | afterEach(() => { 387 | jasmine.clock().uninstall(); 388 | }); 389 | 390 | it('fails the request with "timedOut: true" if it times out without retries', done => { 391 | const url = faker.internet.url(); 392 | const method = 'GET'; 393 | new SimpleWebRequest(method, url, { timeout: 10, customErrorHandler: () => ErrorHandlingType.DoNotRetry }) 394 | .start() 395 | .then(() => { 396 | expect(false).toBeTruthy(); 397 | done(); 398 | }) 399 | .catch(errResp => { 400 | expect(errResp.timedOut).toBeTruthy(); 401 | done(); 402 | }); 403 | 404 | asyncTick().then(() => { 405 | jasmine.clock().tick(10); 406 | }); 407 | }); 408 | 409 | it('timedOut flag is reset on retry', done => { 410 | const url = faker.internet.url(); 411 | const method = 'GET'; 412 | const req = new SimpleWebRequest(method, url, { 413 | timeout: 10, 414 | retries: 1, 415 | customErrorHandler: () => ErrorHandlingType.RetryCountedWithBackoff, 416 | }); 417 | const requestPromise = req.start(); 418 | 419 | requestPromise 420 | .then(() => { 421 | expect(false).toBeTruthy(); 422 | done(); 423 | }) 424 | .catch(errResp => { 425 | expect(errResp.canceled).toBeTruthy(); 426 | expect(errResp.timedOut).toBeFalsy(); 427 | done(); 428 | }); 429 | 430 | // first try will time out, the second one will be aborted 431 | asyncTick().then(() => { 432 | jasmine.clock().tick(10); 433 | req.abort(); 434 | }); 435 | }); 436 | }); 437 | 438 | // @TODO Add more unit tests 439 | }); 440 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | export const REQUEST_OPTIONS = { 2 | excludeEndpointUrl: false, 3 | withCredentials: false, 4 | contentType: 'json', 5 | priority: 2, 6 | retries: 0, 7 | }; 8 | 9 | export const REQUEST_HEADERS = { 10 | 'Content-Type': 'application/json', 11 | 'Accept': 'application/json', 12 | }; 13 | 14 | export const DETAILED_RESPONSE = { 15 | requestOptions: REQUEST_OPTIONS, 16 | requestHeaders: REQUEST_HEADERS, 17 | statusCode: 200, 18 | statusText: undefined, 19 | headers: { 'content-type': 'application/json' }, 20 | body: '', 21 | url: '', 22 | }; 23 | 24 | export function asyncTick(): Promise { 25 | return new Promise((res, rej) => { 26 | setTimeout(res, 0); 27 | jasmine.clock().tick(10); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "forceConsistentCasingInFileNames": true, 4 | "noImplicitReturns": true, 5 | "strictNullChecks": true, 6 | "noImplicitAny": true, 7 | "noImplicitThis": true, 8 | "noUnusedLocals": true, 9 | "declaration": true, 10 | "noResolve": false, 11 | "sourceMap": true, 12 | "module": "commonjs", 13 | "target": "es5", 14 | "outDir": "dist", 15 | "lib": ["dom", "es5", "es2015"] 16 | }, 17 | 18 | "include": [ 19 | "src/**/*" 20 | ], 21 | 22 | "exclude": [ 23 | "dist", 24 | "node_modules" 25 | ] 26 | } -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "sourceMap": true 5 | }, 6 | "include": [ 7 | "src/**/*", 8 | "test/**/*" 9 | ] 10 | } 11 | --------------------------------------------------------------------------------