From 17b9add03a90aec6e708a87c0fc387745f0b9df6 Mon Sep 17 00:00:00 2001 From: Ben Lesh Date: Fri, 21 Aug 2020 16:30:30 -0500 Subject: [PATCH] refactor(ajax): Use simple Observable - Refactors `ajax` to use a simple `Observable` implementation. - Moves code to a new ajax-specific folder. - Adds a lot more documentation to classes and support classes. - Differentiates between the configuration passed to `ajax` (`AjaxConfig`) and the request values used to make the HTTP request (`AjaxRequest`), as the latter has more required values. - Ensures that no configuration values are mutated while making the request. - Ensures there is at least one valid test for `ajax.patch`. The old one was sketchy. - Adds better comments throughout the code. - Adds better typing to `ajax` functions. BREAKING CHANGE: For TypeScript users, `AjaxRequest` is no longer the type that should be explicitly used to create an `ajax`. It is now `AjaxConfig`, although the two types are compatible, only `AjaxConfig` has `progressSubscriber` and `createXHR`. --- api_guard/dist/types/ajax/index.d.ts | 50 ++- spec/observables/dom/ajax-spec.ts | 149 ++++--- src/ajax/index.ts | 6 +- src/internal/ajax/AjaxResponse.ts | 41 ++ src/internal/{observable/dom => ajax}/ajax.ts | 258 +++++++++-- src/internal/ajax/errors.ts | 114 +++++ src/internal/ajax/getXHRResponse.ts | 38 ++ src/internal/ajax/types.ts | 164 +++++++ src/internal/observable/dom/AjaxObservable.ts | 417 ------------------ 9 files changed, 716 insertions(+), 521 deletions(-) create mode 100644 src/internal/ajax/AjaxResponse.ts rename src/internal/{observable/dom => ajax}/ajax.ts (50%) create mode 100644 src/internal/ajax/errors.ts create mode 100644 src/internal/ajax/getXHRResponse.ts create mode 100644 src/internal/ajax/types.ts delete mode 100644 src/internal/observable/dom/AjaxObservable.ts diff --git a/api_guard/dist/types/ajax/index.d.ts b/api_guard/dist/types/ajax/index.d.ts index 29be76b949..76a2ed7caa 100644 --- a/api_guard/dist/types/ajax/index.d.ts +++ b/api_guard/dist/types/ajax/index.d.ts @@ -1,40 +1,52 @@ export declare const ajax: AjaxCreationMethod; -export interface AjaxError extends Error { - request: AjaxRequest; - response: any; - responseType: XMLHttpRequestResponseType; - status: number; - xhr: XMLHttpRequest; -} - -export declare const AjaxError: AjaxErrorCtor; - -export interface AjaxRequest { +export interface AjaxConfig { async?: boolean; body?: any; createXHR?: () => XMLHttpRequest; crossDomain?: boolean; - hasContent?: boolean; - headers?: object; + headers?: Readonly>; method?: string; password?: string; progressSubscriber?: PartialObserver; - responseType?: string; + responseType?: XMLHttpRequestResponseType; timeout?: number; - url?: string; + url: string; user?: string; withCredentials?: boolean; } -export declare class AjaxResponse { - originalEvent: Event; +export interface AjaxError extends Error { request: AjaxRequest; response: any; - responseText: string; - responseType: string; + responseType: XMLHttpRequestResponseType; status: number; xhr: XMLHttpRequest; +} + +export declare const AjaxError: AjaxErrorCtor; + +export interface AjaxRequest { + async: boolean; + body?: any; + crossDomain: boolean; + headers: Readonly>; + method: string; + password?: string; + responseType: XMLHttpRequestResponseType; + timeout: number; + url: string; + user?: string; + withCredentials: boolean; +} + +export declare class AjaxResponse { + readonly originalEvent: Event; + readonly request: AjaxRequest; + readonly response: T; + readonly responseType: XMLHttpRequestResponseType; + readonly status: number; + readonly xhr: XMLHttpRequest; constructor(originalEvent: Event, xhr: XMLHttpRequest, request: AjaxRequest); } diff --git a/spec/observables/dom/ajax-spec.ts b/spec/observables/dom/ajax-spec.ts index 132ba5261d..4a10cb7066 100644 --- a/spec/observables/dom/ajax-spec.ts +++ b/spec/observables/dom/ajax-spec.ts @@ -1,7 +1,7 @@ /** @prettier */ import { expect } from 'chai'; import * as sinon from 'sinon'; -import { ajax, AjaxRequest, AjaxResponse, AjaxError, AjaxTimeoutError } from 'rxjs/ajax'; +import { ajax, AjaxConfig, AjaxResponse, AjaxError, AjaxTimeoutError } from 'rxjs/ajax'; import { TestScheduler } from 'rxjs/testing'; import { noop } from 'rxjs'; @@ -29,7 +29,7 @@ describe('ajax', () => { }); it('should create default XMLHttpRequest for non CORS', () => { - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/', method: '', }; @@ -42,7 +42,7 @@ describe('ajax', () => { root.XMLHttpRequest = null; root.ActiveXObject = null; - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/', method: '', }; @@ -51,7 +51,7 @@ describe('ajax', () => { }); it('should create XMLHttpRequest for CORS', () => { - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/', method: '', crossDomain: true, @@ -66,7 +66,7 @@ describe('ajax', () => { root.XMLHttpRequest = null; root.XDomainRequest = null; - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/', method: '', crossDomain: true, @@ -77,7 +77,7 @@ describe('ajax', () => { }); it('should set headers', () => { - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/talk-to-me-goose', headers: { 'Content-Type': 'kenny/loggins', @@ -121,7 +121,7 @@ describe('ajax', () => { }); it('should not set default Content-Type header when no body is sent', () => { - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/talk-to-me-goose', method: 'GET', }; @@ -162,7 +162,7 @@ describe('ajax', () => { it('should error if send request throws', (done: MochaDone) => { const expected = new Error('xhr send failure'); - const obj = { + ajax({ url: '/flibbertyJibbet', responseType: 'text', method: '', @@ -173,9 +173,7 @@ describe('ajax', () => { }; return ret as any; }, - }; - - ajax(obj).subscribe( + }).subscribe( () => { done(new Error('should not be called')); }, @@ -191,15 +189,14 @@ describe('ajax', () => { it('should succeed on 200', () => { const expected = { foo: 'bar' }; - let result: AjaxResponse; + let result: AjaxResponse; let complete = false; - const obj = { + + ajax({ url: '/flibbertyJibbet', responseType: 'text', method: '', - }; - - ajax(obj).subscribe( + }).subscribe( (x: any) => { result = x; }, @@ -224,7 +221,7 @@ describe('ajax', () => { it('should fail if fails to parse response', () => { let error: any; - const obj = { + const obj: AjaxConfig = { url: '/flibbertyJibbet', responseType: 'json', method: '', @@ -255,11 +252,8 @@ describe('ajax', () => { it('should fail on 404', () => { let error: any; - const obj = { + const obj: AjaxConfig = { url: '/flibbertyJibbet', - normalizeError: (e: any, xhr: any) => { - return xhr.response || xhr.responseText; - }, responseType: 'text', method: '', }; @@ -291,13 +285,10 @@ describe('ajax', () => { }); it('should succeed on 300', () => { - let result: AjaxResponse; + let result: AjaxResponse; let complete = false; - const obj = { + const obj: AjaxConfig = { url: '/flibbertyJibbet', - normalizeError: (e: any, xhr: any) => { - return xhr.response || xhr.responseText; - }, responseType: 'text', method: '', }; @@ -327,11 +318,8 @@ describe('ajax', () => { it('should not fail if fails to parse error response', () => { let error: any; - const obj = { + const obj: AjaxConfig = { url: '/flibbertyJibbet', - normalizeError: (e: any, xhr: any) => { - return xhr.response || xhr.responseText; - }, responseType: 'json', method: '', }; @@ -407,7 +395,7 @@ describe('ajax', () => { }); it('should create an asynchronous request', () => { - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/flibbertyJibbet', responseType: 'text', timeout: 10, @@ -440,7 +428,7 @@ describe('ajax', () => { it('should error on timeout of asynchronous request', () => { const rxTestScheduler = new TestScheduler(noop); - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/flibbertyJibbet', responseType: 'text', timeout: 10, @@ -475,7 +463,7 @@ describe('ajax', () => { }); it('should create a synchronous request', () => { - const obj: AjaxRequest = { + const obj: AjaxConfig = { url: '/flibbertyJibbet', responseType: 'text', timeout: 10, @@ -626,7 +614,7 @@ describe('ajax', () => { it('should error if send request throws', (done: MochaDone) => { const expected = new Error('xhr send failure'); - const obj = { + const obj: AjaxConfig = { url: '/flibbertyJibbet', responseType: 'text', method: '', @@ -747,7 +735,7 @@ describe('ajax', () => { describe('ajax.post', () => { it('should succeed on 200', () => { const expected = { foo: 'bar', hi: 'there you' }; - let result: AjaxResponse; + let result: AjaxResponse; let complete = false; ajax.post('/flibbertyJibbet', expected).subscribe( @@ -782,7 +770,7 @@ describe('ajax', () => { it('should properly encode full URLs passed', () => { const expected = { test: 'https://google.com/search?q=encodeURI+vs+encodeURIComponent' }; - let result: AjaxResponse; + let result: AjaxResponse; let complete = false; ajax.post('/flibbertyJibbet', expected).subscribe( @@ -817,7 +805,7 @@ describe('ajax', () => { it('should succeed on 204 No Content', () => { const expected: null = null; - let result: AjaxResponse; + let result: AjaxResponse; let complete = false; ajax.post('/flibbertyJibbet', expected).subscribe( @@ -924,7 +912,7 @@ describe('ajax', () => { configurable: true, }); - const ajaxRequest = { + const ajaxRequest: AjaxConfig = { url: '/flibbertyJibbet', }; @@ -937,9 +925,20 @@ describe('ajax', () => { const request = MockXMLHttpRequest.mostRecent; try { - request.ontimeout('ontimeout'); + request.ontimeout('ontimeout' as any); } catch (e) { - expect(e.message).to.equal(new AjaxTimeoutError(request, ajaxRequest).message); + expect(e.message).to.equal(new AjaxTimeoutError(request as any, { + url: ajaxRequest.url, + method: 'GET', + headers: { + 'content-type': 'application/json;encoding=Utf-8', + }, + withCredentials: false, + async: true, + timeout: 0, + crossDomain: false, + responseType: 'json' + }).message); } delete root.XMLHttpRequest.prototype.ontimeout; }); @@ -1025,34 +1024,84 @@ describe('ajax', () => { describe('ajax.patch', () => { it('should create an AjaxObservable with correct options', () => { - const body = { foo: 'bar' }; - const headers = { first: 'first' }; - // returns Observable, not AjaxObservable, so needs a cast - const { request } = ajax.patch('/flibbertyJibbet', body, headers); + const expected = { foo: 'bar', hi: 'there you' }; + let result: AjaxResponse; + let complete = false; + + ajax.patch('/flibbertyJibbet', expected).subscribe( + (x) => { + result = x; + }, + null, + () => { + complete = true; + } + ); + + const request = MockXMLHttpRequest.mostRecent; expect(request.method).to.equal('PATCH'); expect(request.url).to.equal('/flibbertyJibbet'); - expect(request.body).to.equal(body); - expect(request.headers).to.equal(headers); + expect(request.requestHeaders).to.deep.equal({ + 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'x-requested-with': 'XMLHttpRequest', + }); + + request.respondWith({ + status: 200, + contentType: 'application/json', + responseText: JSON.stringify(expected), + }); + + expect(request.data).to.equal('foo=bar&hi=there%20you'); + expect(result!.response).to.deep.equal(expected); + expect(complete).to.be.true; }); }); describe('ajax error classes', () => { describe('AjaxError', () => { it('should extend Error class', () => { - const error = new AjaxError('Test error', new XMLHttpRequest(), {}); + const error = new AjaxError('Test error', new XMLHttpRequest(), { + url: '/', + method: 'GET', + responseType: 'json', + headers: {}, + withCredentials: false, + async: true, + timeout: 0, + crossDomain: false, + }); expect(error).to.be.an.instanceOf(Error); }); }); describe('AjaxTimeoutError', () => { it('should extend Error class', () => { - const error = new AjaxTimeoutError(new XMLHttpRequest(), {}); + const error = new AjaxTimeoutError(new XMLHttpRequest(), { + url: '/', + method: 'GET', + responseType: 'json', + headers: {}, + withCredentials: false, + async: true, + timeout: 0, + crossDomain: false, + }); expect(error).to.be.an.instanceOf(Error); }); it('should extend AjaxError class', () => { - const error = new AjaxTimeoutError(new XMLHttpRequest(), {}); + const error = new AjaxTimeoutError(new XMLHttpRequest(), { + url: '/', + method: 'GET', + responseType: 'json', + headers: {}, + withCredentials: false, + async: true, + timeout: 0, + crossDomain: false, + }); expect(error).to.be.an.instanceOf(AjaxError); }); }); @@ -1129,7 +1178,7 @@ class MockXMLHttpRequest { // noop } - open(method: any, url: any, async: any, user: any, password: any): void { + open(method: any, url: any, async: any): void { this.method = method; this.url = url; this.async = async; diff --git a/src/ajax/index.ts b/src/ajax/index.ts index 73cb0cc8b0..0007b7fd92 100644 --- a/src/ajax/index.ts +++ b/src/ajax/index.ts @@ -1,2 +1,4 @@ -export { ajax } from '../internal/observable/dom/ajax'; -export { AjaxRequest, AjaxResponse, AjaxError, AjaxTimeoutError } from '../internal/observable/dom/AjaxObservable'; +export { ajax } from '../internal/ajax/ajax'; +export { AjaxError, AjaxTimeoutError } from '../internal/ajax/errors'; +export { AjaxResponse } from '../internal/ajax/AjaxResponse'; +export { AjaxRequest, AjaxConfig } from '../internal/ajax/types'; diff --git a/src/internal/ajax/AjaxResponse.ts b/src/internal/ajax/AjaxResponse.ts new file mode 100644 index 0000000000..5adb7d3563 --- /dev/null +++ b/src/internal/ajax/AjaxResponse.ts @@ -0,0 +1,41 @@ +/** @prettier */ + +import { AjaxRequest } from './types'; +import { getXHRResponse } from './getXHRResponse'; + +/** + * A normalized response from an AJAX request. To get the data from the response, + * you will want to read the `response` property. + * + * - DO NOT create instances of this class directly. + * - DO NOT subclass this class. + * + * @see {@link ajax} + */ +export class AjaxResponse { + /** The HTTP status code */ + readonly status: number; + + /** The response data */ + readonly response: T; + + /** The responseType from the response. (For example: `""`, "arraybuffer"`, "blob"`, "document"`, "json"`, or `"text"`) */ + readonly responseType: XMLHttpRequestResponseType; + + /** + * A normalized response from an AJAX request. To get the data from the response, + * you will want to read the `response` property. + * + * - DO NOT create instances of this class directly. + * - DO NOT subclass this class. + * + * @param originalEvent The original event object from the XHR `onload` event. + * @param xhr The `XMLHttpRequest` object used to make the request. This is useful for examining status code, etc. + * @param request The request settings used to make the HTTP request. + */ + constructor(public readonly originalEvent: Event, public readonly xhr: XMLHttpRequest, public readonly request: AjaxRequest) { + this.status = xhr.status; + this.responseType = xhr.responseType; + this.response = getXHRResponse(xhr); + } +} diff --git a/src/internal/observable/dom/ajax.ts b/src/internal/ajax/ajax.ts similarity index 50% rename from src/internal/observable/dom/ajax.ts rename to src/internal/ajax/ajax.ts index 071ce1ead2..4924d06ec4 100644 --- a/src/internal/observable/dom/ajax.ts +++ b/src/internal/ajax/ajax.ts @@ -1,14 +1,17 @@ /** @prettier */ -import { AjaxObservable, AjaxRequest, AjaxResponse } from './AjaxObservable'; - -import { map } from '../../operators/map'; -import { Observable } from '../../Observable'; +import { map } from '../operators/map'; +import { Observable } from '../Observable'; +import { AjaxConfig, AjaxRequest } from './types'; +import { AjaxResponse } from './AjaxResponse'; +import { AjaxTimeoutError, AjaxError } from './errors'; export interface AjaxCreationMethod { /** - * Perform an HTTP GET using the + * Creates an observable that will perform an AJAX request using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in - * global scope. Defaults to a `responseType` of `"json"`. + * global scope by default. + * + * This is the most configurable option, and the basis for all other AJAX calls in the library. * * ### Example * ```ts @@ -16,7 +19,11 @@ export interface AjaxCreationMethod { * import { map, catchError } from 'rxjs/operators'; * import { of } from 'rxjs'; * - * const obs$ = ajax(`https://api.github.com/users?per_page=5`).pipe( + * const obs$ = ajax({ + * method: 'GET', + * url: `https://api.github.com/users?per_page=5`, + * responseType: 'json', + * }).pipe( * map(userResponse => console.log('users: ', userResponse)), * catchError(error => { * console.log('error: ', error); @@ -25,14 +32,12 @@ export interface AjaxCreationMethod { * ); * ``` */ - (url: string): Observable; + (config: AjaxConfig): Observable>; /** - * Creates an observable that will perform an AJAX request using the + * Perform an HTTP GET using the * [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in - * global scope by default. - * - * This is the most configurable option, and the basis for all other AJAX calls in the library. + * global scope. Defaults to a `responseType` of `"json"`. * * ### Example * ```ts @@ -40,11 +45,7 @@ export interface AjaxCreationMethod { * import { map, catchError } from 'rxjs/operators'; * import { of } from 'rxjs'; * - * const obs$ = ajax({ - * method: 'GET', - * url: `https://api.github.com/users?per_page=5`, - * responseType: 'json', - * }).pipe( + * const obs$ = ajax(`https://api.github.com/users?per_page=5`).pipe( * map(userResponse => console.log('users: ', userResponse)), * catchError(error => { * console.log('error: ', error); @@ -53,7 +54,7 @@ export interface AjaxCreationMethod { * ); * ``` */ - (config: AjaxRequest): Observable; + (url: string): Observable>; /** * Performs an HTTP GET using the @@ -63,7 +64,7 @@ export interface AjaxCreationMethod { * @param url The URL to get the resource from * @param headers Optional headers. Case-Insensitive. */ - get(url: string, headers?: Record): Observable; + get(url: string, headers?: Record): Observable>; /** * Performs an HTTP POST using the @@ -79,7 +80,7 @@ export interface AjaxCreationMethod { * @param body The content to send. The body is automatically serialized. * @param headers Optional headers. Case-Insensitive. */ - post(url: string, body?: any, headers?: Record): Observable; + post(url: string, body?: any, headers?: Record): Observable>; /** * Performs an HTTP PUT using the @@ -95,7 +96,7 @@ export interface AjaxCreationMethod { * @param body The content to send. The body is automatically serialized. * @param headers Optional headers. Case-Insensitive. */ - put(url: string, body?: any, headers?: Record): Observable; + put(url: string, body?: any, headers?: Record): Observable>; /** * Performs an HTTP PATCH using the @@ -111,7 +112,7 @@ export interface AjaxCreationMethod { * @param body The content to send. The body is automatically serialized. * @param headers Optional headers. Case-Insensitive. */ - patch(url: string, body?: any, headers?: Record): Observable; + patch(url: string, body?: any, headers?: Record): Observable>; /** * Performs an HTTP DELETE using the @@ -121,7 +122,7 @@ export interface AjaxCreationMethod { * @param url The URL to get the resource from * @param headers Optional headers. Case-Insensitive. */ - delete(url: string, headers?: Record): Observable; + delete(url: string, headers?: Record): Observable>; /** * Performs an HTTP GET using the @@ -135,34 +136,33 @@ export interface AjaxCreationMethod { getJSON(url: string, headers?: Record): Observable; } -function ajaxGet(url: string, headers?: Record) { +function ajaxGet(url: string, headers?: Record): Observable> { return ajax({ method: 'GET', url, headers }); } -function ajaxPost(url: string, body?: any, headers?: Record): Observable { +function ajaxPost(url: string, body?: any, headers?: Record): Observable> { return ajax({ method: 'POST', url, body, headers }); } -function ajaxDelete(url: string, headers?: Record): Observable { +function ajaxDelete(url: string, headers?: Record): Observable> { return ajax({ method: 'DELETE', url, headers }); } -function ajaxPut(url: string, body?: any, headers?: Record): Observable { +function ajaxPut(url: string, body?: any, headers?: Record): Observable> { return ajax({ method: 'PUT', url, body, headers }); } -function ajaxPatch(url: string, body?: any, headers?: Record): Observable { +function ajaxPatch(url: string, body?: any, headers?: Record): Observable> { return ajax({ method: 'PATCH', url, body, headers }); } -const mapResponse = map((x: AjaxResponse) => x.response); +const mapResponse = map((x: AjaxResponse) => x.response); function ajaxGetJSON(url: string, headers?: Record): Observable { return mapResponse( - ajax({ + ajax({ method: 'GET', url, - responseType: 'json', headers, }) ); @@ -249,8 +249,14 @@ function ajaxGetJSON(url: string, headers?: Record): Observab * ``` */ export const ajax: AjaxCreationMethod = (() => { - const create = (urlOrRequest: string | AjaxRequest) => { - return new AjaxObservable(urlOrRequest); + const create = (urlOrRequest: string | AjaxConfig) => { + const request = + typeof urlOrRequest === 'string' + ? { + url: urlOrRequest, + } + : urlOrRequest; + return fromAjax(request); }; create.get = ajaxGet; @@ -262,3 +268,189 @@ export const ajax: AjaxCreationMethod = (() => { return create; })(); + +function isFormData(body: any): body is FormData { + return typeof FormData !== 'undefined' && body instanceof FormData; +} + +export function fromAjax(config: AjaxConfig): Observable> { + return new Observable>((destination) => { + let done = false; + + // Normalize the headers. We're going to make them all lowercase, since + // Headers are case insenstive by design. This makes it easier to verify + // that we aren't setting or sending duplicates. + const headers: Record = {}; + const requestHeaders = config.headers; + if (requestHeaders) { + for (const key in requestHeaders) { + if (requestHeaders.hasOwnProperty(key)) { + headers[key.toLowerCase()] = requestHeaders[key]; + } + } + } + + // Set the x-requested-with header. This is a non-standard header that has + // come to be a defacto standard for HTTP requests sent by libraries and frameworks + // using XHR. However, we DO NOT want to set this if it is a CORS request. This is + // because sometimes this header can cause issues with CORS. To be clear, + // None of this is necessary, it's only being set because it's "the thing libraries do" + // Starting back as far as JQuery, and continuing with other libraries such as Angular 1, + // Axios, et al. + if (!config.crossDomain && !('x-requested-with' in headers)) { + headers['x-requested-with'] = 'XMLHttpRequest'; + } + + // Ensure content type is set + if (!('content-type' in headers) && config.body !== undefined && !isFormData(config.body)) { + headers['content-type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; + } + + const body: string | undefined = serializeBody(config.body, headers['content-type']); + + const _request: AjaxRequest = { + // Default values + async: true, + crossDomain: true, + withCredentials: false, + method: 'GET', + timeout: 0, + responseType: 'json' as XMLHttpRequestResponseType, + + // Override with passed user values + ...config, + + // Set values we ensured above + headers, + body, + }; + + let xhr: XMLHttpRequest; + + try { + const { url } = _request; + if (!url) { + throw new TypeError('url is required'); + } + + // Create our XHR so we can get started. + xhr = config.createXHR ? config.createXHR() : new XMLHttpRequest(); + + { + /////////////////////////////////////////////////// + // set up the events before open XHR + // https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest + // You need to add the event listeners before calling open() on the request. + // Otherwise the progress events will not fire. + /////////////////////////////////////////////////// + + const progressSubscriber = config.progressSubscriber; + + xhr.ontimeout = (e: ProgressEvent) => { + const timeoutError = new AjaxTimeoutError(xhr, _request); + progressSubscriber?.error?.(timeoutError); + destination.error(timeoutError); + }; + + if (progressSubscriber) { + xhr.upload.onprogress = (e: ProgressEvent) => { + progressSubscriber.next?.(e); + }; + } + + xhr.onerror = (e: ProgressEvent) => { + progressSubscriber?.error?.(e); + destination.error(new AjaxError('ajax error', xhr, _request)); + }; + + xhr.onload = (e: ProgressEvent) => { + // 4xx and 5xx should error (https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html) + if (xhr.status < 400) { + progressSubscriber?.complete?.(); + + done = true; + let response: AjaxResponse; + try { + // This can throw in IE, because we end up needing to do a JSON.parse + // of the response in some cases to produce object we'd expect from + // modern browsers. + response = new AjaxResponse(e, xhr, _request); + } catch (err) { + destination.error(err); + return; + } + destination.next(response); + destination.complete(); + } else { + progressSubscriber?.error?.(e); + destination.error(new AjaxError('ajax error ' + xhr.status, xhr, _request)); + } + }; + } + + const { user, method, async } = _request; + // open XHR + if (user) { + xhr.open(method, url, async, user, _request.password); + } else { + xhr.open(method, url, async); + } + + // timeout, responseType and withCredentials can be set once the XHR is open + if (async) { + xhr.timeout = _request.timeout; + xhr.responseType = _request.responseType; + } + + if ('withCredentials' in xhr) { + xhr.withCredentials = _request.withCredentials; + } + + // set headers + for (const key in headers) { + if (headers.hasOwnProperty(key)) { + xhr.setRequestHeader(key, headers[key]); + } + } + + // finally send the request + if (body) { + xhr.send(body); + } else { + xhr.send(); + } + } catch (err) { + destination.error(err); + } + + return () => { + if (!done && xhr) { + xhr.abort(); + } + }; + }); +} + +function serializeBody(body: any, contentType?: string) { + if (!body || typeof body === 'string' || isFormData(body)) { + return body; + } + + if (contentType) { + const splitIndex = contentType.indexOf(';'); + if (splitIndex !== -1) { + contentType = contentType.substring(0, splitIndex); + } + } + + switch (contentType) { + case 'application/x-www-form-urlencoded': + return Object.keys(body) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(body[key])}`) + .join('&'); + case 'application/json': + return JSON.stringify(body); + default: + return body; + } +} diff --git a/src/internal/ajax/errors.ts b/src/internal/ajax/errors.ts new file mode 100644 index 0000000000..7283289a33 --- /dev/null +++ b/src/internal/ajax/errors.ts @@ -0,0 +1,114 @@ +/** @prettier */ +import { AjaxRequest } from './types'; +import { getXHRResponse } from './getXHRResponse'; + +export type AjaxErrorNames = 'AjaxError' | 'AjaxTimeoutError'; + +/** + * A normalized AJAX error. + * + * @see {@link ajax} + * + * @class AjaxError + */ +export interface AjaxError extends Error { + /** + * The XHR instance associated with the error + */ + xhr: XMLHttpRequest; + + /** + * The AjaxRequest associated with the error + */ + request: AjaxRequest; + + /** + *The HTTP status code + */ + status: number; + + /** + *The responseType (e.g. 'json', 'arraybuffer', or 'xml') + */ + responseType: XMLHttpRequestResponseType; + + /** + * The response data + */ + response: any; +} + +export interface AjaxErrorCtor { + /** + * Internal use only. Do not manually create instances of this type. + * @internal + */ + new (message: string, xhr: XMLHttpRequest, request: AjaxRequest): AjaxError; +} + +const AjaxErrorImpl = (() => { + function AjaxErrorImpl(this: any, message: string, xhr: XMLHttpRequest, request: AjaxRequest): AjaxError { + Error.call(this); + this.message = message; + this.name = 'AjaxError'; + this.xhr = xhr; + this.request = request; + this.status = xhr.status; + this.responseType = xhr.responseType; + let response: any; + try { + // This can throw in IE, because we have to do a JSON.parse of + // the response in some cases to get the expected response property. + response = getXHRResponse(xhr); + } catch (err) { + response = xhr.responseText; + } + this.response = response; + return this; + } + AjaxErrorImpl.prototype = Object.create(Error.prototype); + return AjaxErrorImpl; +})(); + +/** + * Thrown when an error occurs during an AJAX request. + * This is only exported because it is useful for checking to see if an error + * is an `instanceof AjaxError`. DO NOT create new instances of `AjaxError` with + * the constructor. + * + * @class AjaxError + * @see ajax + */ +export const AjaxError: AjaxErrorCtor = AjaxErrorImpl as any; + +export interface AjaxTimeoutError extends AjaxError {} + +export interface AjaxTimeoutErrorCtor { + /** + * Internal use only. Do not manually create instances of this type. + * @internal + */ + new (xhr: XMLHttpRequest, request: AjaxRequest): AjaxTimeoutError; +} + +const AjaxTimeoutErrorImpl = (() => { + function AjaxTimeoutErrorImpl(this: any, xhr: XMLHttpRequest, request: AjaxRequest) { + AjaxError.call(this, 'ajax timeout', xhr, request); + this.name = 'AjaxTimeoutError'; + return this; + } + AjaxTimeoutErrorImpl.prototype = Object.create(AjaxError.prototype); + return AjaxTimeoutErrorImpl; +})(); + +/** + * Thrown when an AJAX request timesout. Not to be confused with {@link TimeoutError}. + * + * This is exported only because it is useful for checking to see if errors are an + * `instanceof AjaxTimeoutError`. DO NOT use the constructor to create an instance of + * this type. + * + * @class AjaxTimeoutError + * @see ajax + */ +export const AjaxTimeoutError: AjaxTimeoutErrorCtor = AjaxTimeoutErrorImpl as any; diff --git a/src/internal/ajax/getXHRResponse.ts b/src/internal/ajax/getXHRResponse.ts new file mode 100644 index 0000000000..7a678f701d --- /dev/null +++ b/src/internal/ajax/getXHRResponse.ts @@ -0,0 +1,38 @@ +/** @prettier */ +/** + * Gets what should be in the `response` property of the XHR. However, + * since we still support the final versions of IE, we need to do a little + * checking here to make sure that we get the right thing back. Conquentally, + * we need to do a JSON.parse() in here, which *could* throw if the response + * isn't valid JSON. + * + * This is used both in creating an AjaxResponse, and in creating certain errors + * that we throw, so we can give the user whatever was in the response property. + * + * @param xhr The XHR to examine the response of + */ +export function getXHRResponse(xhr: XMLHttpRequest) { + switch (xhr.responseType) { + case 'json': { + if ('response' in xhr) { + return xhr.response; + } else { + // IE + const ieXHR: any = xhr; + return JSON.parse(ieXHR.responseText); + } + } + case 'document': + return xhr.responseXML; + case 'text': + default: { + if ('response' in xhr) { + return xhr.response; + } else { + // IE + const ieXHR: any = xhr; + return ieXHR.responseText; + } + } + } +} diff --git a/src/internal/ajax/types.ts b/src/internal/ajax/types.ts new file mode 100644 index 0000000000..d1c58f2946 --- /dev/null +++ b/src/internal/ajax/types.ts @@ -0,0 +1,164 @@ +/** @prettier */ +import { PartialObserver } from '../types'; + +/** + * The object containing values RxJS used to make the HTTP request. + * + * This is provided in {@link AjaxError} instances and + */ +export interface AjaxRequest { + /** + * The URL requested. + */ + url: string; + + /** + * The body to send over the HTTP request. + */ + body?: any; + + /** + * The HTTP method used to make the HTTP request. + */ + method: string; + + /** + * Whether or not the request was made asynchronously. + */ + async: boolean; + + /** + * The headers sent over the HTTP request. + */ + headers: Readonly>; + + /** + * The timeout value used for the HTTP request. + * Note: this is only honored if the request is asynchronous (`async` is `true`). + */ + timeout: number; + + /** + * The user credentials user name sent with the HTTP request. + */ + user?: string; + + /** + * The user credentials password sent with the HTTP request. + */ + password?: string; + + /** + * Whether or not the request was a CORS request. + */ + crossDomain: boolean; + + /** + * Whether or not a CORS request was sent with credentials. + * If `false`, will also ignore cookies in the CORS response. + */ + withCredentials: boolean; + + /** + * The [`responseType`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType) set before sending the request. + */ + responseType: XMLHttpRequestResponseType; +} + +/** + * Configuration for the {@link ajax} creation function. + */ +export interface AjaxConfig { + /** The address of the resource to request via HTTP. */ + url: string; + + /** + * The body of the HTTP request to send. + * + * This is serialized, by default, based off of the valud of the `"content-type"` header. + * For example, if the `"content-type"` is `"application/json"`, the body will be serialized + * as JSON. If the `"content-type"` is `"application/x-www-form-urlencoded"`, whatever object passed + * to the body will be serialized as URL, using key-value pairs based off of the keys and values of the object. + * In all other cases, the body will be passed directly. + */ + body?: any; + + /** + * Whether or not to send the request asynchronously. Defaults to `true`. + * If set to `false`, this will block the thread until the AJAX request responds. + */ + async?: boolean; + + /** + * The HTTP Method to use for the request. Defaults to "GET". + */ + method?: string; + + /** + * The HTTP headers to apply. + * + * Note that, by default, RxJS will add the following headers under certain conditions: + * + * 1. If the `"content-type"` header is **NOT** set, and the `body` is [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData), + * a `"content-type"` of `"application/x-www-form-urlencoded; charset=UTF-8"` will be set automatically. + * 2. If the `"x-requested-with"` header is **NOT** set, and the `crossDomain` configuration property is **NOT** explicitly set to `true`, + * (meaning it is not a CORS request), a `"x-requested-with"` header with a value of `"XMLHttpRequest"` will be set automatically. + * This header is generally meaningless, and is set by libraries and frameworks using `XMLHttpRequest` to make HTTP requests. + */ + headers?: Readonly>; + + /** + * The time to wait before causing the underlying XMLHttpRequest to timeout. This is only honored if the + * `async` configuration setting is unset or set to `true`. Defaults to `0`, which is idiomatic for "never timeout". + */ + timeout?: number; + + /** The user credentials user name to send with the HTTP request */ + user?: string; + + /** The user credentials password to send with the HTTP request*/ + password?: string; + + /** + * Whether or not to send the HTTP request as a CORS request. + * Defaults to `false`. + */ + crossDomain?: boolean; + + /** + * To send user credentials in a CORS request, set to `true`. To exclude user credentials from + * a CORS request, _OR_ when cookies are to be ignored by the CORS response, set to `false`. + * + * Defaults to `false`. + */ + withCredentials?: boolean; + + /** + * Can be set to change the response type. + * Valid values are `"arraybuffer"`, `"blob"`, `"document"`, `"json"`, and `"text"`. + * Note that the type of `"document"` (such as an XML document) is ignored if the global context is + * not `Window`. + * + * Defaults to `"json"`. + */ + responseType?: XMLHttpRequestResponseType; + + /** + * An optional factory used to create the XMLHttpRequest object used to make the AJAX request. + * This is useful in environments that lack `XMLHttpRequest`, or in situations where you + * wish to override the default `XMLHttpRequest` for some reason. + * + * If not provided, the `XMLHttpRequest` in global scope will be used. + */ + createXHR?: () => XMLHttpRequest; + + /** + * An observer for watching the progress of an HTTP request. Will + * emit progress events, and completes on the final load event, will error for + * any XHR error or timeout. + * + * This will **not** error for errored status codes. Rather, it will always _complete_ when + * the HTTP response comes back. + */ + progressSubscriber?: PartialObserver; +} diff --git a/src/internal/observable/dom/AjaxObservable.ts b/src/internal/observable/dom/AjaxObservable.ts deleted file mode 100644 index 398b6e4e4d..0000000000 --- a/src/internal/observable/dom/AjaxObservable.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** @prettier */ -import { Observable } from '../../Observable'; -import { Subscriber } from '../../Subscriber'; -import { TeardownLogic, PartialObserver } from '../../types'; -import { createErrorClass } from '../../util/createErrorClass'; - -export interface AjaxRequest { - url?: string; - body?: any; - user?: string; - async?: boolean; - method?: string; - headers?: Readonly>; - timeout?: number; - password?: string; - hasContent?: boolean; - crossDomain?: boolean; - withCredentials?: boolean; - createXHR?: () => XMLHttpRequest; - progressSubscriber?: PartialObserver; - responseType?: string; -} - -function isFormData(body: any): body is FormData { - return typeof FormData !== 'undefined' && body instanceof FormData; -} - -/** - * We need this JSDoc comment for affecting ESDoc. - * @extends {Ignored} - * @hide true - */ -export class AjaxObservable extends Observable { - private request: AjaxRequest; - - constructor(urlOrRequest: string | AjaxRequest) { - super(); - - this.request = - typeof urlOrRequest === 'string' - ? { - url: urlOrRequest, - } - : urlOrRequest; - } - - /** @deprecated This is an internal implementation detail, do not use. */ - _subscribe(subscriber: Subscriber): TeardownLogic { - return new AjaxSubscriber(subscriber, this.request); - } -} - -/** - * We need this JSDoc comment for affecting ESDoc. - * @ignore - * @extends {Ignored} - */ -export class AjaxSubscriber extends Subscriber { - private xhr?: XMLHttpRequest; - private done: boolean = false; - public request: AjaxRequest; - - constructor(destination: Subscriber, request: AjaxRequest) { - super(destination); - - // Normalize the headers. We're going to make them all lowercase, since - // Headers are case insenstive by design. This makes it easier to verify - // that we aren't setting or sending duplicates. - const headers: Record = {}; - const requestHeaders = request.headers; - if (requestHeaders) { - for (const key in requestHeaders) { - if (requestHeaders.hasOwnProperty(key)) { - headers[key.toLowerCase()] = requestHeaders[key]; - } - } - } - - // Set the x-requested-with header. This is a non-standard header that has - // come to be a defacto standard for HTTP requests sent by libraries and frameworks - // using XHR. However, we DO NOT want to set this if it is a CORS request. This is - // because sometimes this header can cause issues with CORS. To be clear, - // None of this is necessary, it's only being set because it's "the thing libraries do" - // Starting back as far as JQuery, and continuing with other libraries such as Angular 1, - // Axios, et al. - if (!request.crossDomain && !('x-requested-with' in headers)) { - headers['x-requested-with'] = 'XMLHttpRequest'; - } - - // Ensure content type is set - if (!('content-type' in headers) && request.body !== undefined && !isFormData(request.body)) { - headers['content-type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; - } - - const body: string | undefined = serializeBody(request.body, headers['content-type']); - - this.request = { - // Default values - async: true, - crossDomain: true, - withCredentials: false, - method: 'GET', - responseType: 'json', - timeout: 0, - - // Override with passed user values - ...request, - - // Set values we ensured above - headers, - body, - }; - - // Create our XHR so we can get started. - try { - this.xhr = request.createXHR ? request.createXHR() : new XMLHttpRequest(); - this.send(); - } catch (err) { - this.error(err); - } - } - - _next(e: Event): void { - this.done = true; - const destination = this.destination as Subscriber; - let result: AjaxResponse; - try { - result = new AjaxResponse(e, this.xhr!, this.request); - } catch (err) { - destination.error(err); - return; - } - destination.next(result); - } - - private send(): void { - const { - request, - request: { user, createXHR, method, url, async, password, headers, body }, - } = this; - - const xhr = (this.xhr = createXHR ? createXHR() : new XMLHttpRequest()); - - // set up the events before open XHR - // https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest - // You need to add the event listeners before calling open() on the request. - // Otherwise the progress events will not fire. - this.setupEvents(xhr, request); - - // open XHR - if (user) { - xhr.open(method!, url!, async!, user, password); - } else { - xhr.open(method!, url!, async!); - } - - // timeout, responseType and withCredentials can be set once the XHR is open - if (async) { - xhr.timeout = request.timeout!; - xhr.responseType = request.responseType as any; - } - - if ('withCredentials' in xhr) { - xhr.withCredentials = !!request.withCredentials; - } - - // set headers - for (const key in headers) { - if (headers.hasOwnProperty(key)) { - xhr.setRequestHeader(key, headers[key]); - } - } - - // finally send the request - if (body) { - xhr.send(body); - } else { - xhr.send(); - } - } - - private setupEvents(xhr: XMLHttpRequest, request: AjaxRequest) { - const progressSubscriber = request.progressSubscriber; - - xhr.ontimeout = (e: ProgressEvent) => { - progressSubscriber?.error?.(e); - let error; - try { - error = new AjaxTimeoutError(xhr, request); // TODO: Make betterer. - } catch (err) { - error = err; - } - this.error(error); - }; - - if (progressSubscriber) { - xhr.upload.onprogress = (e: ProgressEvent) => { - progressSubscriber.next?.(e); - }; - } - - xhr.onerror = (e: ProgressEvent) => { - progressSubscriber?.error?.(e); - this.error(new AjaxError('ajax error', xhr, request)); - }; - - xhr.onload = (e: ProgressEvent) => { - // 4xx and 5xx should error (https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html) - if (xhr.status < 400) { - progressSubscriber?.complete?.(); - this.next(e); - this.complete(); - } else { - progressSubscriber?.error?.(e); - let error; - try { - error = new AjaxError('ajax error ' + xhr.status, xhr, request); - } catch (err) { - error = err; - } - this.error(error); - } - }; - } - - unsubscribe() { - const { done, xhr } = this; - if (!done && xhr) { - xhr.abort(); - } - super.unsubscribe(); - } -} - -/** - * A normalized AJAX response. - * - * @see {@link ajax} - * - * @class AjaxResponse - */ -export class AjaxResponse { - /** @type {number} The HTTP status code */ - status: number; - - /** @type {string|ArrayBuffer|Document|object|any} The response data */ - response: any; - - /** @type {string} The raw responseText */ - // @ts-ignore: Property has no initializer and is not definitely assigned - responseText: string; - - /** @type {string} The responseType (e.g. 'json', 'arraybuffer', or 'xml') */ - responseType: string; - - constructor(public originalEvent: Event, public xhr: XMLHttpRequest, public request: AjaxRequest) { - this.status = xhr.status; - this.responseType = xhr.responseType || request.responseType!; - this.response = getXHRResponse(xhr); - } -} - -export type AjaxErrorNames = 'AjaxError' | 'AjaxTimeoutError'; - -/** - * A normalized AJAX error. - * - * @see {@link ajax} - * - * @class AjaxError - */ -export interface AjaxError extends Error { - /** - * The XHR instance associated with the error - */ - xhr: XMLHttpRequest; - - /** - * The AjaxRequest associated with the error - */ - request: AjaxRequest; - - /** - *The HTTP status code - */ - status: number; - - /** - *The responseType (e.g. 'json', 'arraybuffer', or 'xml') - */ - responseType: XMLHttpRequestResponseType; - - /** - * The response data - */ - response: any; -} - -export interface AjaxErrorCtor { - /** - * Internal use only. Do not manually create instances of this type. - * @internal - */ - new (message: string, xhr: XMLHttpRequest, request: AjaxRequest): AjaxError; -} - -/** - * Thrown when an error occurs during an AJAX request. - * This is only exported because it is useful for checking to see if an error - * is an `instanceof AjaxError`. DO NOT create new instances of `AjaxError` with - * the constructor. - * - * @class AjaxError - * @see ajax - */ -export const AjaxError: AjaxErrorCtor = createErrorClass( - (_super) => - function AjaxError(this: any, message: string, xhr: XMLHttpRequest, request: AjaxRequest) { - _super(this); - this.message = message; - this.xhr = xhr; - this.request = request; - this.status = xhr.status; - this.responseType = xhr.responseType; - let response: any; - try { - response = getXHRResponse(xhr); - } catch (err) { - response = xhr.responseText; - } - this.response = response; - } -); - -function getXHRResponse(xhr: XMLHttpRequest) { - switch (xhr.responseType) { - case 'json': { - if ('response' in xhr) { - return xhr.response; - } else { - // IE - const ieXHR: any = xhr; - return JSON.parse(ieXHR.responseText); - } - } - case 'document': - return xhr.responseXML; - case 'text': - default: { - if ('response' in xhr) { - return xhr.response; - } else { - // IE - const ieXHR: any = xhr; - return ieXHR.responseText; - } - } - } -} - -export interface AjaxTimeoutError extends AjaxError {} - -export interface AjaxTimeoutErrorCtor { - /** - * Internal use only. Do not manually create instances of this type. - * @internal - */ - new (xhr: XMLHttpRequest, request: AjaxRequest): AjaxTimeoutError; -} - -// NOTE: We are not using createErrorClass here, because we're deriving this from -// the AjaxError we defined above. -const AjaxTimeoutErrorImpl = (() => { - function AjaxTimeoutErrorImpl(this: any, xhr: XMLHttpRequest, request: AjaxRequest) { - AjaxError.call(this, 'ajax timeout', xhr, request); - this.name = 'AjaxTimeoutError'; - return this; - } - AjaxTimeoutErrorImpl.prototype = Object.create(AjaxError.prototype); - return AjaxTimeoutErrorImpl; -})(); - -/** - * Thrown when an AJAX request timesout. Not to be confused with {@link TimeoutError}. - * - * This is exported only because it is useful for checking to see if errors are an - * `instanceof AjaxTimeoutError`. DO NOT use the constructor to create an instance of - * this type. - * - * @class AjaxTimeoutError - * @see ajax - */ -export const AjaxTimeoutError: AjaxTimeoutErrorCtor = AjaxTimeoutErrorImpl as any; - -function serializeBody(body: any, contentType?: string) { - if (!body || typeof body === 'string' || isFormData(body)) { - return body; - } - - if (contentType) { - const splitIndex = contentType.indexOf(';'); - if (splitIndex !== -1) { - contentType = contentType.substring(0, splitIndex); - } - } - - switch (contentType) { - case 'application/x-www-form-urlencoded': - return Object.keys(body) - .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(body[key])}`) - .join('&'); - case 'application/json': - return JSON.stringify(body); - default: - return body; - } -}