@react-mvi/http
Version:
Http IO module for React MVI.
375 lines (374 loc) • 15 kB
JavaScript
/**
* The MIT License (MIT)
* Copyright (c) Taketoshi Aono
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
* @fileoverview
* @author Taketoshi Aono
*/
import { isDefined, StateHandler, } from '@react-mvi/core';
import { Observable, Subscription, ConnectableObservable, Subject } from 'rxjs';
import { HttpResponseImpl, HttpUploadProgressImpl } from './http-response';
import { qs } from './qs';
import { HttpMethod, ResponseType, UploadEventType, } from './types';
const DEFAULT_ERROR_STATUS = 500;
/**
* Http request sender.
*/
export class HttpHandler extends StateHandler {
constructor(a) {
super(a, {
request: ['get', 'post', 'put', 'delete', 'upload', 'patch'],
response: 'notifyResponse',
uploading: 'notifyUploading',
});
this.history = [];
}
static set maxHistoryLength(length) {
this._maxHistoryLength = length;
}
static get maxHistoryLenght() {
return this._maxHistoryLength;
}
clone() {
return new HttpHandler(this.advices);
}
/**
* Wait for request from observables.
* @override
* @param request Observable that send request.
*/
subscribe(props) {
const subscription = new Subscription();
if (props.http) {
if (props.http instanceof Observable) {
subscription.add(props.http.subscribe(args => {
if (Array.isArray(args)) {
args.forEach(({ type, request }) => this.push(type, request));
}
else {
this.push(args.type, args.request);
}
}, error => console.error(error)));
}
else {
for (const reqKey in props.http) {
const req = props.http[reqKey];
subscription.add(req.subscribe((config) => this.push(reqKey, config), error => console.error(error)));
}
for (const reqKey in props.http) {
const req = props.http[reqKey];
if (req instanceof ConnectableObservable && req['connect']) {
req.connect();
}
}
}
}
return subscription;
}
/**
* @inheritDoc
*/
async push(key, args) {
if (key === 'RETRY') {
const history = this.history[this.history.length - (typeof args === 'number' ? args + 1 : 1)];
if (!history) {
return new Promise((_, r) => r(new Error('Invlaid retry number specified.')));
}
key = history.key;
args = history.args;
}
else {
if (this.history.length > HttpHandler._maxHistoryLength) {
this.history.shift();
}
this.history.push({ key, args });
}
if (!args) {
return new Promise((_, r) => r(new Error('Config required.')));
}
const config = args;
const subjectsOK = this.store.get(key).concat(this.store.get(`${key}-ok`));
const subjectsNG = this.store.get(key).concat(this.store.get(`${key}-ng`));
const subjectsProgress = this.store
.get(key)
.concat(this.store.get(`${key}-uploading`));
const errorHandler = (config, err, result) => {
const httpResponse = new HttpResponseImpl(false, err && err.status ? err.status : DEFAULT_ERROR_STATUS, {}, null, result);
const ret = config.reduce(httpResponse, this.state);
this.notifyResponse(config, `${key}-ng`, httpResponse, ret, subjectsNG);
};
const succeededHandler = (config, response, result) => {
const headers = this.processHeaders(response);
const httpResponse = new HttpResponseImpl(response.ok, response.status, headers, response.ok ? result : null, response.ok ? null : result);
const ret = config.reduce(httpResponse, this.state);
this.notifyResponse(config, `${key}-ok`, httpResponse, ret, subjectsOK);
};
if (!config.reduce) {
config.reduce = v => v;
}
if (config.upload) {
return this.upload(config, key).then(subject => {
this.handleUploadResonse(subjectsOK, subjectsNG, subjectsProgress, subject, config, key);
});
}
await this.handleResponse(config, key, (res, ret) => succeededHandler(config, res, ret), (e, ret) => errorHandler(config, e, ret));
}
handleUploadResonse(subjectsOK, subjectsNG, subjectsUploading, subject, config, key) {
const sub = subject.subscribe(e => {
if (e.type !== UploadEventType.PROGRESS) {
sub.unsubscribe();
const isComplete = e.type === UploadEventType.COMPLETE;
const contentType = e.xhr.getResponseHeader('Content-Type') || '';
const response = config.responseType === ResponseType.JSON ||
contentType.indexOf('application/json') > -1
? JSON.parse(e.xhr.responseText)
: e.xhr.responseText;
const headers = e.xhr.getAllResponseHeaders();
const headerArr = headers.split('\n');
const headerMap = {};
headerArr.forEach(e => {
const [key, value] = e.split(':');
if (key && value) {
headerMap[key.trim()] = value.trim();
}
});
const httpResponse = new HttpResponseImpl(e.type === UploadEventType.COMPLETE, e.xhr.status, headerMap, isComplete ? response : null, isComplete ? null : response);
const ret = config.reduce(httpResponse, this.state);
this.notifyResponse(config, e.type === UploadEventType.COMPLETE ? `${key}-ok` : `${key}-ng`, httpResponse, ret, isComplete ? subjectsOK : subjectsNG);
}
else {
const httpResponse = new HttpUploadProgressImpl(e.event, e.xhr);
this.notifyUploading(config, `${key}-uploading`, httpResponse, subjectsUploading);
}
}, error => console.error(error));
}
notifyUploading(config, key, progress, subjects) {
subjects.forEach(subject => subject.next({ data: progress, state: this.state }));
this.subject && this.subject.notify({ type: key, payload: progress });
}
notifyResponse(config, key, httpResponse, results, subjects) {
subjects.forEach(subject => subject.next({ data: results, state: this.state }));
this.subject && this.subject.notify({ type: key, payload: results });
}
async handleResponse(config, key, succeededHandler, errorHandler) {
try {
const res = await (() => {
switch (config.method) {
case HttpMethod.GET:
return this.get(config, key);
case HttpMethod.POST:
return this.post(config, key);
case HttpMethod.PUT:
return this.put(config, key);
case HttpMethod.PATCH:
return this.patch(config, key);
case HttpMethod.DELETE:
return this.delete(config, key);
default:
return this.get(config, key);
}
})();
if (!res.ok) {
throw res;
}
// For IE|Edge
if (!res.url) {
const u = 'ur' + 'l';
try {
res[u] = config.url;
}
catch (e) { }
}
const resp = this.getResponse(config, key, config.responseType, res);
if (resp && resp.then) {
const ret = await resp;
succeededHandler(res, ret);
}
}
catch (err) {
if (err && typeof err.json === 'function') {
const resp = this.getResponse(config, key, this.getResponseTypeFromHeader(err), err);
if (resp && resp.then) {
try {
const e = await resp;
errorHandler(err, e);
}
catch (e) {
errorHandler(err, e);
}
}
}
else {
errorHandler(err, err);
}
}
}
processHeaders(res) {
const headers = {};
res.headers.forEach((v, k) => (headers[k] = v));
return headers;
}
getFetcher() {
return fetch;
}
/**
* Send GET request.
* @data url Target url.
* @data data GET parameters.
* @returns Promise that return response.
*/
get({ url, headers = {}, data = null, mode }, key) {
return this.getFetcher()(data ? `${url}${qs(data)}` : url, {
method: 'GET',
headers,
mode: mode || 'same-origin',
});
}
/**
* Send POST request.
* @data url Target url.
* @data data POST body.
* @returns Promise that return response.
*/
post({ url, headers = {}, data = {}, json = true, form = false, mode, }, key) {
return this.getFetcher()(url, {
headers,
method: 'POST',
mode: mode || 'same-origin',
body: json ? JSON.stringify(data) : form ? qs(data) : data,
});
}
/**
* Send PUT request.
* @data url Target url.
* @data data PUT body.
* @returns Promise that return response.
*/
put({ url, headers = {}, data = {}, json = true, form = false, mode, }, key) {
return this.getFetcher()(url, {
headers,
method: 'PUT',
mode: mode || 'same-origin',
body: json ? JSON.stringify(data) : form ? qs(data) : data,
});
}
/**
* Send PATCH request.
* @data url Target url.
* @data data PUT body.
* @returns Promise that return response.
*/
patch({ url, headers = {}, data = {}, json = true, form = false, mode, }, key) {
return this.getFetcher()(url, {
headers,
method: 'PATCH',
mode: mode || 'same-origin',
body: json ? JSON.stringify(data) : form ? qs(data) : data,
});
}
/**
* Send DELETE request.
* @data url Target url.
* @data data PUT body.
* @returns Promise that return response.
*/
delete({ url, headers = {}, data = {}, json = true, form = false, mode, }, key) {
const req = {
headers,
method: 'DELETE',
mode: mode || 'same-origin',
};
if (isDefined(data)) {
req.body = json ? JSON.stringify(data) : form ? qs(data) : data;
}
return this.getFetcher()(url, req);
}
upload({ method, url, headers = {}, data = {}, mode }, key) {
const xhr = new XMLHttpRequest();
const subject = new Subject();
const events = {};
const addEvent = (xhr, type, fn, dispose = false) => {
events[type] = e => {
if (dispose) {
for (const key in events) {
xhr.removeEventListener(key, events[key]);
}
}
fn(e);
};
xhr.addEventListener(type, events[type], false);
};
if (xhr.upload) {
addEvent(xhr.upload, 'progress', e => subject.next({ type: UploadEventType.PROGRESS, event: e, xhr }));
}
addEvent(xhr, 'error', e => subject.next({ type: UploadEventType.ERROR, event: e, xhr }), true);
addEvent(xhr, 'abort', e => subject.next({ type: UploadEventType.ABORT, event: e, xhr }), true);
addEvent(xhr, 'load', e => {
if (!xhr.upload) {
subject.next({
type: UploadEventType.PROGRESS,
event: { total: 1, loaded: 1 },
xhr,
});
}
subject.next({ type: UploadEventType.COMPLETE, event: e, xhr });
}, true);
xhr.open(HttpMethod[method], url, true);
for (const key in headers) {
xhr.setRequestHeader(key, headers[key]);
}
xhr.send(data);
return Promise.resolve(subject);
}
/**
* Get proper response from fetch response body.
* @param responseType The type of response. ex. ARRAY_BUFFER, BLOB, etc...
* @param res Http response.
* @returns
*/
getResponse(config, key, responseType, res) {
switch (responseType) {
case ResponseType.ARRAY_BUFFER:
return res.arrayBuffer();
case ResponseType.BLOB:
return res.blob();
case ResponseType.FORM_DATA:
return res.formData();
case ResponseType.JSON:
return res.json();
case ResponseType.TEXT:
return res.text();
case ResponseType.STREAM:
return Promise.resolve(res.body);
default:
return res.text();
}
}
getResponseTypeFromHeader(res) {
const mime = res.headers.get('content-type');
if (!mime || mime.indexOf('text/plain') > -1) {
return ResponseType.TEXT;
}
if (mime.indexOf('text/json') > -1 ||
mime.indexOf('application/json') > -1) {
return ResponseType.JSON;
}
if (/^(?:image|audio|video|(?:application\/zip)|(?:application\/octet-stream))/.test(mime)) {
return ResponseType.BLOB;
}
return ResponseType.TEXT;
}
}
HttpHandler.displayName = 'HttpHandler';
HttpHandler._maxHistoryLength = 10;