import { map } from '../operators/map';
|
import { Observable } from '../Observable';
|
import { AjaxConfig, AjaxRequest, AjaxDirection, ProgressEventType } from './types';
|
import { AjaxResponse } from './AjaxResponse';
|
import { AjaxTimeoutError, AjaxError } from './errors';
|
|
export interface AjaxCreationMethod {
|
/**
|
* 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 by default.
|
*
|
* This is the most configurable option, and the basis for all other AJAX calls in the library.
|
*
|
* ## Example
|
*
|
* ```ts
|
* import { ajax } from 'rxjs/ajax';
|
* import { map, catchError, of } from 'rxjs';
|
*
|
* 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);
|
* return of(error);
|
* })
|
* );
|
* ```
|
*/
|
<T>(config: AjaxConfig): Observable<AjaxResponse<T>>;
|
|
/**
|
* Perform an HTTP GET using the
|
* [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in
|
* global scope. Defaults to a `responseType` of `"json"`.
|
*
|
* ## Example
|
*
|
* ```ts
|
* import { ajax } from 'rxjs/ajax';
|
* import { map, catchError, of } from 'rxjs';
|
*
|
* const obs$ = ajax('https://api.github.com/users?per_page=5').pipe(
|
* map(userResponse => console.log('users: ', userResponse)),
|
* catchError(error => {
|
* console.log('error: ', error);
|
* return of(error);
|
* })
|
* );
|
* ```
|
*/
|
<T>(url: string): Observable<AjaxResponse<T>>;
|
|
/**
|
* Performs an HTTP GET using the
|
* [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in
|
* global scope by default, and a `responseType` of `"json"`.
|
*
|
* @param url The URL to get the resource from
|
* @param headers Optional headers. Case-Insensitive.
|
*/
|
get<T>(url: string, headers?: Record<string, string>): Observable<AjaxResponse<T>>;
|
|
/**
|
* Performs an HTTP POST using the
|
* [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in
|
* global scope by default, and a `responseType` of `"json"`.
|
*
|
* Before sending the value passed to the `body` argument, it is automatically serialized
|
* based on the specified `responseType`. By default, a JavaScript object will be serialized
|
* to JSON. A `responseType` of `application/x-www-form-urlencoded` will flatten any provided
|
* dictionary object to a url-encoded string.
|
*
|
* @param url The URL to get the resource from
|
* @param body The content to send. The body is automatically serialized.
|
* @param headers Optional headers. Case-Insensitive.
|
*/
|
post<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>>;
|
|
/**
|
* Performs an HTTP PUT using the
|
* [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in
|
* global scope by default, and a `responseType` of `"json"`.
|
*
|
* Before sending the value passed to the `body` argument, it is automatically serialized
|
* based on the specified `responseType`. By default, a JavaScript object will be serialized
|
* to JSON. A `responseType` of `application/x-www-form-urlencoded` will flatten any provided
|
* dictionary object to a url-encoded string.
|
*
|
* @param url The URL to get the resource from
|
* @param body The content to send. The body is automatically serialized.
|
* @param headers Optional headers. Case-Insensitive.
|
*/
|
put<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>>;
|
|
/**
|
* Performs an HTTP PATCH using the
|
* [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in
|
* global scope by default, and a `responseType` of `"json"`.
|
*
|
* Before sending the value passed to the `body` argument, it is automatically serialized
|
* based on the specified `responseType`. By default, a JavaScript object will be serialized
|
* to JSON. A `responseType` of `application/x-www-form-urlencoded` will flatten any provided
|
* dictionary object to a url-encoded string.
|
*
|
* @param url The URL to get the resource from
|
* @param body The content to send. The body is automatically serialized.
|
* @param headers Optional headers. Case-Insensitive.
|
*/
|
patch<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>>;
|
|
/**
|
* Performs an HTTP DELETE using the
|
* [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in
|
* global scope by default, and a `responseType` of `"json"`.
|
*
|
* @param url The URL to get the resource from
|
* @param headers Optional headers. Case-Insensitive.
|
*/
|
delete<T>(url: string, headers?: Record<string, string>): Observable<AjaxResponse<T>>;
|
|
/**
|
* Performs an HTTP GET using the
|
* [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) in
|
* global scope by default, and returns the hydrated JavaScript object from the
|
* response.
|
*
|
* @param url The URL to get the resource from
|
* @param headers Optional headers. Case-Insensitive.
|
*/
|
getJSON<T>(url: string, headers?: Record<string, string>): Observable<T>;
|
}
|
|
function ajaxGet<T>(url: string, headers?: Record<string, string>): Observable<AjaxResponse<T>> {
|
return ajax({ method: 'GET', url, headers });
|
}
|
|
function ajaxPost<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>> {
|
return ajax({ method: 'POST', url, body, headers });
|
}
|
|
function ajaxDelete<T>(url: string, headers?: Record<string, string>): Observable<AjaxResponse<T>> {
|
return ajax({ method: 'DELETE', url, headers });
|
}
|
|
function ajaxPut<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>> {
|
return ajax({ method: 'PUT', url, body, headers });
|
}
|
|
function ajaxPatch<T>(url: string, body?: any, headers?: Record<string, string>): Observable<AjaxResponse<T>> {
|
return ajax({ method: 'PATCH', url, body, headers });
|
}
|
|
const mapResponse = map((x: AjaxResponse<any>) => x.response);
|
|
function ajaxGetJSON<T>(url: string, headers?: Record<string, string>): Observable<T> {
|
return mapResponse(
|
ajax<T>({
|
method: 'GET',
|
url,
|
headers,
|
})
|
);
|
}
|
|
/**
|
* There is an ajax operator on the Rx object.
|
*
|
* It creates an observable for an Ajax request with either a request object with
|
* url, headers, etc or a string for a URL.
|
*
|
* ## Examples
|
*
|
* Using `ajax()` to fetch the response object that is being returned from API
|
*
|
* ```ts
|
* import { ajax } from 'rxjs/ajax';
|
* import { map, catchError, of } from 'rxjs';
|
*
|
* const obs$ = ajax('https://api.github.com/users?per_page=5').pipe(
|
* map(userResponse => console.log('users: ', userResponse)),
|
* catchError(error => {
|
* console.log('error: ', error);
|
* return of(error);
|
* })
|
* );
|
*
|
* obs$.subscribe({
|
* next: value => console.log(value),
|
* error: err => console.log(err)
|
* });
|
* ```
|
*
|
* Using `ajax.getJSON()` to fetch data from API
|
*
|
* ```ts
|
* import { ajax } from 'rxjs/ajax';
|
* import { map, catchError, of } from 'rxjs';
|
*
|
* const obs$ = ajax.getJSON('https://api.github.com/users?per_page=5').pipe(
|
* map(userResponse => console.log('users: ', userResponse)),
|
* catchError(error => {
|
* console.log('error: ', error);
|
* return of(error);
|
* })
|
* );
|
*
|
* obs$.subscribe({
|
* next: value => console.log(value),
|
* error: err => console.log(err)
|
* });
|
* ```
|
*
|
* Using `ajax()` with object as argument and method POST with a two seconds delay
|
*
|
* ```ts
|
* import { ajax } from 'rxjs/ajax';
|
* import { map, catchError, of } from 'rxjs';
|
*
|
* const users = ajax({
|
* url: 'https://httpbin.org/delay/2',
|
* method: 'POST',
|
* headers: {
|
* 'Content-Type': 'application/json',
|
* 'rxjs-custom-header': 'Rxjs'
|
* },
|
* body: {
|
* rxjs: 'Hello World!'
|
* }
|
* }).pipe(
|
* map(response => console.log('response: ', response)),
|
* catchError(error => {
|
* console.log('error: ', error);
|
* return of(error);
|
* })
|
* );
|
*
|
* users.subscribe({
|
* next: value => console.log(value),
|
* error: err => console.log(err)
|
* });
|
* ```
|
*
|
* Using `ajax()` to fetch. An error object that is being returned from the request
|
*
|
* ```ts
|
* import { ajax } from 'rxjs/ajax';
|
* import { map, catchError, of } from 'rxjs';
|
*
|
* const obs$ = ajax('https://api.github.com/404').pipe(
|
* map(userResponse => console.log('users: ', userResponse)),
|
* catchError(error => {
|
* console.log('error: ', error);
|
* return of(error);
|
* })
|
* );
|
*
|
* obs$.subscribe({
|
* next: value => console.log(value),
|
* error: err => console.log(err)
|
* });
|
* ```
|
*/
|
export const ajax: AjaxCreationMethod = (() => {
|
const create = <T>(urlOrConfig: string | AjaxConfig) => {
|
const config: AjaxConfig =
|
typeof urlOrConfig === 'string'
|
? {
|
url: urlOrConfig,
|
}
|
: urlOrConfig;
|
return fromAjax<T>(config);
|
};
|
|
create.get = ajaxGet;
|
create.post = ajaxPost;
|
create.delete = ajaxDelete;
|
create.put = ajaxPut;
|
create.patch = ajaxPatch;
|
create.getJSON = ajaxGetJSON;
|
|
return create;
|
})();
|
|
const UPLOAD = 'upload';
|
const DOWNLOAD = 'download';
|
const LOADSTART = 'loadstart';
|
const PROGRESS = 'progress';
|
const LOAD = 'load';
|
|
export function fromAjax<T>(init: AjaxConfig): Observable<AjaxResponse<T>> {
|
return new Observable((destination) => {
|
const config = {
|
// Defaults
|
async: true,
|
crossDomain: false,
|
withCredentials: false,
|
method: 'GET',
|
timeout: 0,
|
responseType: 'json' as XMLHttpRequestResponseType,
|
|
...init,
|
};
|
|
const { queryParams, body: configuredBody, headers: configuredHeaders } = config;
|
|
let url = config.url;
|
if (!url) {
|
throw new TypeError('url is required');
|
}
|
|
if (queryParams) {
|
let searchParams: URLSearchParams;
|
if (url.includes('?')) {
|
// If the user has passed a URL with a querystring already in it,
|
// we need to combine them. So we're going to split it. There
|
// should only be one `?` in a valid URL.
|
const parts = url.split('?');
|
if (2 < parts.length) {
|
throw new TypeError('invalid url');
|
}
|
// Add the passed queryParams to the params already in the url provided.
|
searchParams = new URLSearchParams(parts[1]);
|
// queryParams is converted to any because the runtime is *much* more permissive than
|
// the types are.
|
new URLSearchParams(queryParams as any).forEach((value, key) => searchParams.set(key, value));
|
// We have to do string concatenation here, because `new URL(url)` does
|
// not like relative URLs like `/this` without a base url, which we can't
|
// specify, nor can we assume `location` will exist, because of node.
|
url = parts[0] + '?' + searchParams;
|
} else {
|
// There is no preexisting querystring, so we can just use URLSearchParams
|
// to convert the passed queryParams into the proper format and encodings.
|
// queryParams is converted to any because the runtime is *much* more permissive than
|
// the types are.
|
searchParams = new URLSearchParams(queryParams as any);
|
url = url + '?' + searchParams;
|
}
|
}
|
|
// Normalize the headers. We're going to make them all lowercase, since
|
// Headers are case insensitive by design. This makes it easier to verify
|
// that we aren't setting or sending duplicates.
|
const headers: Record<string, any> = {};
|
if (configuredHeaders) {
|
for (const key in configuredHeaders) {
|
if (configuredHeaders.hasOwnProperty(key)) {
|
headers[key.toLowerCase()] = configuredHeaders[key];
|
}
|
}
|
}
|
|
const crossDomain = config.crossDomain;
|
|
// Set the x-requested-with header. This is a non-standard header that has
|
// come to be a de facto 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 (!crossDomain && !('x-requested-with' in headers)) {
|
headers['x-requested-with'] = 'XMLHttpRequest';
|
}
|
|
// Allow users to provide their XSRF cookie name and the name of a custom header to use to
|
// send the cookie.
|
const { withCredentials, xsrfCookieName, xsrfHeaderName } = config;
|
if ((withCredentials || !crossDomain) && xsrfCookieName && xsrfHeaderName) {
|
const xsrfCookie = document?.cookie.match(new RegExp(`(^|;\\s*)(${xsrfCookieName})=([^;]*)`))?.pop() ?? '';
|
if (xsrfCookie) {
|
headers[xsrfHeaderName] = xsrfCookie;
|
}
|
}
|
|
// Examine the body and determine whether or not to serialize it
|
// and set the content-type in `headers`, if we're able.
|
const body = extractContentTypeAndMaybeSerializeBody(configuredBody, headers);
|
|
// The final request settings.
|
const _request: Readonly<AjaxRequest> = {
|
...config,
|
|
// Set values we ensured above
|
url,
|
headers,
|
body,
|
};
|
|
let xhr: XMLHttpRequest;
|
|
// Create our XHR so we can get started.
|
xhr = init.createXHR ? init.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, includeDownloadProgress = false, includeUploadProgress = false } = init;
|
|
/**
|
* Wires up an event handler that will emit an error when fired. Used
|
* for timeout and abort events.
|
* @param type The type of event we're treating as an error
|
* @param errorFactory A function that creates the type of error to emit.
|
*/
|
const addErrorEvent = (type: string, errorFactory: () => any) => {
|
xhr.addEventListener(type, () => {
|
const error = errorFactory();
|
progressSubscriber?.error?.(error);
|
destination.error(error);
|
});
|
};
|
|
// If the request times out, handle errors appropriately.
|
addErrorEvent('timeout', () => new AjaxTimeoutError(xhr, _request));
|
|
// If the request aborts (due to a network disconnection or the like), handle
|
// it as an error.
|
addErrorEvent('abort', () => new AjaxError('aborted', xhr, _request));
|
|
/**
|
* Creates a response object to emit to the consumer.
|
* @param direction the direction related to the event. Prefixes the event `type` in the
|
* `AjaxResponse` object with "upload_" for events related to uploading and "download_"
|
* for events related to downloading.
|
* @param event the actual event object.
|
*/
|
const createResponse = (direction: AjaxDirection, event: ProgressEvent) =>
|
new AjaxResponse<T>(event, xhr, _request, `${direction}_${event.type as ProgressEventType}` as const);
|
|
/**
|
* Wires up an event handler that emits a Response object to the consumer, used for
|
* all events that emit responses, loadstart, progress, and load.
|
* Note that download load handling is a bit different below, because it has
|
* more logic it needs to run.
|
* @param target The target, either the XHR itself or the Upload object.
|
* @param type The type of event to wire up
|
* @param direction The "direction", used to prefix the response object that is
|
* emitted to the consumer. (e.g. "upload_" or "download_")
|
*/
|
const addProgressEvent = (target: any, type: string, direction: AjaxDirection) => {
|
target.addEventListener(type, (event: ProgressEvent) => {
|
destination.next(createResponse(direction, event));
|
});
|
};
|
|
if (includeUploadProgress) {
|
[LOADSTART, PROGRESS, LOAD].forEach((type) => addProgressEvent(xhr.upload, type, UPLOAD));
|
}
|
|
if (progressSubscriber) {
|
[LOADSTART, PROGRESS].forEach((type) => xhr.upload.addEventListener(type, (e: any) => progressSubscriber?.next?.(e)));
|
}
|
|
if (includeDownloadProgress) {
|
[LOADSTART, PROGRESS].forEach((type) => addProgressEvent(xhr, type, DOWNLOAD));
|
}
|
|
const emitError = (status?: number) => {
|
const msg = 'ajax error' + (status ? ' ' + status : '');
|
destination.error(new AjaxError(msg, xhr, _request));
|
};
|
|
xhr.addEventListener('error', (e) => {
|
progressSubscriber?.error?.(e);
|
emitError();
|
});
|
|
xhr.addEventListener(LOAD, (event) => {
|
const { status } = xhr;
|
// 4xx and 5xx should error (https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html)
|
if (status < 400) {
|
progressSubscriber?.complete?.();
|
|
let response: AjaxResponse<T>;
|
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 = createResponse(DOWNLOAD, event);
|
} catch (err) {
|
destination.error(err);
|
return;
|
}
|
|
destination.next(response);
|
destination.complete();
|
} else {
|
progressSubscriber?.error?.(event);
|
emitError(status);
|
}
|
});
|
}
|
|
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();
|
}
|
|
return () => {
|
if (xhr && xhr.readyState !== 4 /*XHR done*/) {
|
xhr.abort();
|
}
|
};
|
});
|
}
|
|
/**
|
* Examines the body to determine if we need to serialize it for them or not.
|
* If the body is a type that XHR handles natively, we just allow it through,
|
* otherwise, if the body is something that *we* can serialize for the user,
|
* we will serialize it, and attempt to set the `content-type` header, if it's
|
* not already set.
|
* @param body The body passed in by the user
|
* @param headers The normalized headers
|
*/
|
function extractContentTypeAndMaybeSerializeBody(body: any, headers: Record<string, string>) {
|
if (
|
!body ||
|
typeof body === 'string' ||
|
isFormData(body) ||
|
isURLSearchParams(body) ||
|
isArrayBuffer(body) ||
|
isFile(body) ||
|
isBlob(body) ||
|
isReadableStream(body)
|
) {
|
// The XHR instance itself can handle serializing these, and set the content-type for us
|
// so we don't need to do that. https://xhr.spec.whatwg.org/#the-send()-method
|
return body;
|
}
|
|
if (isArrayBufferView(body)) {
|
// This is a typed array (e.g. Float32Array or Uint8Array), or a DataView.
|
// XHR can handle this one too: https://fetch.spec.whatwg.org/#concept-bodyinit-extract
|
return body.buffer;
|
}
|
|
if (typeof body === 'object') {
|
// If we have made it here, this is an object, probably a POJO, and we'll try
|
// to serialize it for them. If this doesn't work, it will throw, obviously, which
|
// is okay. The workaround for users would be to manually set the body to their own
|
// serialized string (accounting for circular references or whatever), then set
|
// the content-type manually as well.
|
headers['content-type'] = headers['content-type'] ?? 'application/json;charset=utf-8';
|
return JSON.stringify(body);
|
}
|
|
// If we've gotten past everything above, this is something we don't quite know how to
|
// handle. Throw an error. This will be caught and emitted from the observable.
|
throw new TypeError('Unknown body type');
|
}
|
|
const _toString = Object.prototype.toString;
|
|
function toStringCheck(obj: any, name: string): boolean {
|
return _toString.call(obj) === `[object ${name}]`;
|
}
|
|
function isArrayBuffer(body: any): body is ArrayBuffer {
|
return toStringCheck(body, 'ArrayBuffer');
|
}
|
|
function isFile(body: any): body is File {
|
return toStringCheck(body, 'File');
|
}
|
|
function isBlob(body: any): body is Blob {
|
return toStringCheck(body, 'Blob');
|
}
|
|
function isArrayBufferView(body: any): body is ArrayBufferView {
|
return typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(body);
|
}
|
|
function isFormData(body: any): body is FormData {
|
return typeof FormData !== 'undefined' && body instanceof FormData;
|
}
|
|
function isURLSearchParams(body: any): body is URLSearchParams {
|
return typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams;
|
}
|
|
function isReadableStream(body: any): body is ReadableStream {
|
return typeof ReadableStream !== 'undefined' && body instanceof ReadableStream;
|
}
|