tui-code-snippet
Version:
TOAST UI Utility: CodeSnippet
557 lines (492 loc) • 18.5 kB
JavaScript
import forEachArray from '../collection/forEachArray';
import forEachOwnProperties from '../collection/forEachOwnProperties';
import extend from '../object/extend';
import isArray from '../type/isArray';
import isEmpty from '../type/isEmpty';
import isFunction from '../type/isFunction';
import isNull from '../type/isNull';
import isObject from '../type/isObject';
import isUndefined from '../type/isUndefined';
function encodePairs(key, value) {
return `${encodeURIComponent(key)}=${encodeURIComponent(
isNull(value) || isUndefined(value) ? '' : value
)}`;
}
function serializeParams(key, value, serializedList) {
if (isArray(value)) {
forEachArray(value, (arrVal, index) => {
serializeParams(`${key}[${isObject(arrVal) ? index : ''}]`, arrVal, serializedList);
});
} else if (isObject(value)) {
forEachOwnProperties(value, (objValue, objKey) => {
serializeParams(`${key}[${objKey}]`, objValue, serializedList);
});
} else {
serializedList.push(encodePairs(key, value));
}
}
/**
* Serializer to serialize parameters
* @callback ajax/serializer
* @param {*} params - parameter to serialize
* @returns {string} - serialized strings
*/
/**
* Serializer
*
* 1. Array format
*
* The default array format to serialize is 'bracket'.
* However in case of nested array, only the deepest format follows the 'bracket', the rest follow 'indice' format.
*
* - basic
* { a: [1, 2, 3] } => a[]=1&a[]=2&a[]=3
* - nested
* { a: [1, 2, [3]] } => a[]=1&a[]=2&a[2][]=3
*
* 2. Object format
*
* The default object format to serialize is 'bracket' notation and doesn't allow the 'dot' notation.
*
* - basic
* { a: { b: 1, c: 2 } } => a[b]=1&a[c]=2
*
* @param {*} params - parameters to serialize
* @returns {string}
* @private
*/
function serialize(params) {
if (!params || isEmpty(params)) {
return '';
}
const serializedList = [];
forEachOwnProperties(params, (value, key) => {
serializeParams(key, value, serializedList);
});
return serializedList.join('&');
}
const getDefaultOptions = () => ({
baseURL: '',
headers: {
common: {},
get: {},
post: {},
put: {},
delete: {},
patch: {},
options: {},
head: {}
},
serializer: serialize
});
const HTTP_PROTOCOL_REGEXP = /^(http|https):\/\//i;
/**
* Combine an absolute URL string (baseURL) and a relative URL string (url).
* @param {string} baseURL - An absolute URL string
* @param {string} url - An relative URL string
* @returns {string}
* @private
*/
function combineURL(baseURL, url) {
if (HTTP_PROTOCOL_REGEXP.test(url)) {
return url;
}
if (baseURL.slice(-1) === '/' && url.slice(0, 1) === '/') {
url = url.slice(1);
}
return baseURL + url;
}
/**
* Get merged options by its priorities.
* defaults.common < defaults[method] < custom options
* @param {Object} defaultOptions - The default options
* @param {Object} customOptions - The custom options
* @returns {Object}
* @private
*/
function getComputedOptions(defaultOptions, customOptions) {
const {
baseURL,
headers: defaultHeaders,
serializer: defaultSerializer,
beforeRequest: defaultBeforeRequest,
success: defaultSuccess,
error: defaultError,
complete: defaultComplete
} = defaultOptions;
const {
url,
contentType,
method,
params,
headers,
serializer,
beforeRequest,
success,
error,
complete,
withCredentials,
mimeType
} = customOptions;
const options = {
url: combineURL(baseURL, url),
method,
params,
headers: extend(defaultHeaders.common, defaultHeaders[method.toLowerCase()], headers),
serializer: serializer || defaultSerializer || serialize,
beforeRequest: [defaultBeforeRequest, beforeRequest],
success: [defaultSuccess, success],
error: [defaultError, error],
complete: [defaultComplete, complete],
withCredentials,
mimeType
};
options.contentType = contentType || options.headers['Content-Type'];
delete options.headers['Content-Type'];
return options;
}
function validateStatus(status) {
return status >= 200 && status < 300;
}
function hasRequestBody(method) {
return /^(?:POST|PUT|PATCH)$/.test(method.toUpperCase());
}
function executeCallback(callback, param) {
if (isArray(callback)) {
forEachArray(callback, fn => executeCallback(fn, param));
} else if (isFunction(callback)) {
callback(param);
}
}
function parseHeaders(text) {
const headers = {};
forEachArray(text.split('\r\n'), header => {
const [key, value] = header.split(': ');
if (key !== '' && !isUndefined(value)) {
headers[key] = value;
}
});
return headers;
}
function parseJSONData(data) {
let result = '';
try {
result = JSON.parse(data);
} catch (_) {
result = data;
}
return result;
}
const REQUEST_DONE = 4;
function handleReadyStateChange(xhr, options) {
const { readyState } = xhr;
// eslint-disable-next-line eqeqeq
if (readyState != REQUEST_DONE) {
return;
}
const { status, statusText, responseText } = xhr;
const { success, resolve, error, reject, complete } = options;
if (validateStatus(status)) {
const contentType = xhr.getResponseHeader('Content-Type');
let data = responseText;
if (contentType && contentType.indexOf('application/json') > -1) {
data = parseJSONData(data);
}
/**
* successCallback is executed when the response is received successfully
* @callback ajax/successCallback
* @param {Object} response - success response wrapper
* @param {number} response.status - response status code
* @param {string} response.statusText - response status text
* @param {*} response.data - response data. If its Content-Type is 'application/json', the parsed object will be passed.
* @param {Object.<string,string>} response.headers - response headers
*/
executeCallback([success, resolve], {
status,
statusText,
data,
headers: parseHeaders(xhr.getAllResponseHeaders())
});
} else {
/**
* errorCallback executed when the request failed
* @callback ajax/errorCallback
* @param {Object} response - error response wrapper
* @param {number} response.status - response status code
* @param {string} response.statusText - response status text
*/
executeCallback([error, reject], { status, statusText });
}
/**
* completeCallback executed when the request is completed after success or error callbacks are executed
* @callback ajax/completeCallback
* @param {Object} response - error response wrapper
* @param {number} response.status - response status code
* @param {string} response.statusText - response status text
*/
executeCallback(complete, { status, statusText });
}
const QS_DELIM_REGEXP = /\?/;
function open(xhr, options) {
const { url, method, serializer, params } = options;
let requestUrl = url;
if (!hasRequestBody(method) && params) {
// serialize query string
const qs = (QS_DELIM_REGEXP.test(url) ? '&' : '?') + serializer(params);
requestUrl = `${url}${qs}`;
}
xhr.open(method, requestUrl);
}
function applyConfig(xhr, options) {
const { method, contentType, mimeType, headers, withCredentials = false } = options;
// set withCredentials (IE10+)
if (withCredentials) {
xhr.withCredentials = withCredentials;
}
// override MIME type (IE11+)
if (mimeType) {
xhr.overrideMimeType(mimeType);
}
forEachOwnProperties(headers, (value, header) => {
if (!isObject(value)) {
xhr.setRequestHeader(header, value);
}
});
if (hasRequestBody(method)) {
xhr.setRequestHeader('Content-Type', `${contentType}; charset=UTF-8`);
}
// set 'x-requested-with' header to prevent CSRF in old browser
xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
}
const ENCODED_SPACE_REGEXP = /%20/g;
function send(xhr, options) {
const {
method,
serializer,
beforeRequest,
params = {},
contentType = 'application/x-www-form-urlencoded'
} = options;
let body = null;
if (hasRequestBody(method)) {
// The space character '%20' is replaced to '+', because application/x-www-form-urlencoded follows rfc-1866
body =
contentType.indexOf('application/x-www-form-urlencoded') > -1
? serializer(params).replace(ENCODED_SPACE_REGEXP, '+')
: JSON.stringify(params);
}
xhr.onreadystatechange = () => handleReadyStateChange(xhr, options);
/**
* beforeRequestCallback is executed before the Ajax request is sent
* @callback ajax/beforeRequestCallback
* @param {XMLHttpRequest} xhr - XMLHttpRequest object
*/
executeCallback(beforeRequest, xhr);
xhr.send(body);
}
/**
* @module ajax
* @description
* A module for the Ajax request.
* If the browser supports Promise, return the Promise object. If not, return null.
* @param {Object} options - Options for the Ajax request
* @param {string} options.url - URL string
* @param {('GET'|'POST'|'PUT'|'DELETE'|'PATCH'|'OPTIONS'|'HEAD')} options.method - Method of the Ajax request
* @param {Object.<string,string>} [options.headers] - Headers for the Ajax request
* @param {string} [options.contentType] - Content-Type for the Ajax request. It is applied to POST, PUT, and PATCH requests only. Its encoding automatically sets to UTF-8.
* @param {*} [options.params] - Parameters to send by the Ajax request
* @param {serializer} [options.serializer] - {@link ajax_serializer Serializer} that determine how to serialize the parameters. Default serializer is {@link https://github.com/nhn/tui.code-snippet/tree/v2.3.0/ajax/index.mjs#L38 serialize()}.
* @param {beforeRequestCallback} [options.beforeRequest] - {@link ajax_beforeRequestCallback beforeRequest callback} executed before the Ajax request is sent.
* @param {successCallback} [options.success] - {@link ajax_successCallback success callback} executed when the response is received successfully.
* @param {errorCallback} [options.error] - {@link ajax_errorCallback error callback} executed when the request failed.
* @param {completeCallback} [options.complete] - {@link ajax_completeCallback complete callback} executed when the request is completed after success or error callbacks are executed.
* @param {boolean} [options.withCredentials] - Determine whether cross-site Access-Control requests should be made using credentials or not. This option can be used on IE10+
* @param {string} [options.mimeType] - Override the MIME type returned by the server. This options can be used on IE11+
* @returns {?Promise} - If the browser supports Promise, return the Promise object. If not, return null.
* @example
* // ES6
* import ajax from 'tui-code-snippet/ajax';
*
* // import transfiled file (IE8+)
* import ajax from 'tui-code-snippet/ajax/index.js';
*
* // CommonJS
* const ajax = require('tui-code-snippet/ajax/index.js');
*
* // If the browser supports Promise, return the Promise object
* ajax({
* url: 'https://nhn.github.io/tui-code-snippet/',
* method: 'POST',
* contentType: 'application/json',
* params: {
* version: 'v2.3.0',
* author: 'NHN. FE Development Lab <dl_javascript@nhn.com>'
* },
* success: res => console.log(`success: ${res.status} ${res.statusText}`),
* error: res => console.log(`error: ${res.status} ${res.statusText}`)
* }).then(res => console.log(`resolve: ${res.status} ${res.statusText}`))
* .catch(res => console.log(`reject: ${res.status} ${res.statusText}`));
*
* // If the request succeeds (200, OK)
* // success: 200 OK
* // resolve: 200 OK
*
* // If the request failed (503, Service Unavailable)
* // error: 503 Service Unavailable
* // reject: 503 Service Unavailable
*
* // If the browser does not support Promise, return null
* ajax({
* url: 'https://ui.toast.com/fe-guide/',
* method: 'GET',
* contentType: 'application/json',
* params: {
* lang: 'en'
* title: 'PERFORMANCE',
* },
* success: res => console.log(`success: ${res.status} ${res.statusText}`),
* error: res => console.log(`error: ${res.status} ${res.statusText}`)
* });
*
* // If the request succeeds (200, OK)
* // success: 200 OK
*
* // If the request failed (503, Service Unavailable)
* // error: 503 Service Unavailable
*/
function ajax(options) {
const xhr = new XMLHttpRequest();
const request = opts => forEachArray([open, applyConfig, send], fn => fn(xhr, opts));
options = getComputedOptions(ajax.defaults, options);
if (typeof Promise !== 'undefined') {
return new Promise((resolve, reject) => {
request(extend(options, { resolve, reject }));
});
}
request(options);
return null;
}
/**
* Default configuration for the Ajax request.
* @property {string} baseURL - baseURL appended with url of ajax options. If url is absolute, baseURL is ignored.
* ex) baseURL = 'https://nhn.github.io', url = '/tui.code-snippet' => request is sent to 'https://nhn.github.io/tui.code-snippet'
* ex) baseURL = 'https://nhn.github.io', url = 'https://ui.toast.com' => request is sent to 'https://ui.toast.com'
* @property {Object} headers - request headers. It extends the header object in the following order: headers.common -> headers\[method\] -> headers in ajax options.
* @property {Object.<string,string>} headers.common - Common headers regardless of the type of request
* @property {Object.<string,string>} headers.get - Headers for the GET method
* @property {Object.<string,string>} headers.post - Headers for the POST method
* @property {Object.<string,string>} headers.put - Headers for the PUT method
* @property {Object.<string,string>} headers.delete - Headers for the DELETE method
* @property {Object.<string,string>} headers.patch - Headers for the PATCH method
* @property {Object.<string,string>} headers.options - Headers for the OPTIONS method
* @property {Object.<string,string>} headers.head - Headers for the HEAD method
* @property {serializer} serializer - {@link ajax_serializer Serializer} that determine how to serialize the parameters. If serializer is specified in both default options and ajax options, use serializer in ajax options.
* @property {beforeRequestCallback} beforeRequest - {@link ajax_beforeRequestCallback beforeRequest callback} executed before the Ajax request is sent. Callbacks in both default options and ajax options are executed, but default callbacks are called first.
* @property {successCallback} success - {@link ajax_successCallback success callback} executed when the response is received successfully. Callbacks in both default options and ajax options are executed, but default callbacks are called first.
* @property {errorCallback} error - {@link ajax_errorCallback error callback} executed when the request failed. Callbacks in both default options and ajax options are executed, but default callbacks are called first.
* @property {completeCallback} complete - {@link ajax_completeCallback complete callback} executed when the request is completed after success or error callbacks are executed. Callbacks in both default options and ajax options are executed, but default callbacks are called first.
* @example
* ajax.defaults.baseURL = 'https://nhn.github.io/tui.code-snippet';
* ajax.defaults.headers.common = {
* 'Content-Type': 'application/json'
* };
* ajax.defaults.beforeRequest = () => showProgressBar();
* ajax.defaults.complete = () => hideProgressBar();
*/
ajax.defaults = getDefaultOptions();
/**
* Reset the default options
* @private
*/
ajax._reset = function() {
ajax.defaults = getDefaultOptions();
};
/**
* Ajax request
* @private
*/
ajax._request = function(url, method, options = {}) {
return ajax(extend(options, { url, method }));
};
/**
* Send the Ajax request by GET
* @memberof module:ajax
* @function get
* @param {string} url - URL string
* @param {object} options - Options for the Ajax request. Refer to {@link ajax options of ajax()}.
* @example
* ajax.get('https://nhn.github.io/tui.code-snippet/', {
* params: {
* version: 'v2.3.0'
* }
* });
*/
/**
* Send the Ajax request by POST
* @memberof module:ajax
* @function post
* @param {string} url - URL string
* @param {object} options - Options for the Ajax request. Refer to {@link ajax options of ajax()}.
* @example
* ajax.post('https://nhn.github.io/tui.code-snippet/', {
* contentType: 'application/json',
* params: {
* version: 'v2.3.0'
* }
* });
*/
/**
* Send the Ajax request by PUT
* @memberof module:ajax
* @function put
* @param {string} url - URL string
* @param {object} options - Options for the Ajax request. Refer to {@link ajax options of ajax()}.
* @example
* ajax.put('https://nhn.github.io/tui.code-snippet/v2.3.0', {
* success: ({status, statusText}) => alert(`success: ${status} ${statusText}`),
* error: ({status, statusText}) => alert(`error: ${status} ${statusText}`)
* });
*/
/**
* Send the Ajax request by DELETE
* @memberof module:ajax
* @function delete
* @param {string} url - URL string
* @param {object} options - Options for the Ajax request. Refer to {@link ajax options of ajax()}.
* @example
* ajax.delete('https://nhn.github.io/tui.code-snippet/v2.3.0');
*/
/**
* Send the Ajax request by PATCH
* @memberof module:ajax
* @function patch
* @param {string} url - URL string
* @param {object} options - Options for the Ajax request. Refer to {@link ajax options of ajax()}.
* @example
* ajax.patch('https://nhn.github.io/tui.code-snippet/v2.3.0', {
* beforeRequest: () => showProgressBar(),
* complete: () => hideProgressBar()
* });
*/
/**
* Send the Ajax request by OPTIONS
* @memberof module:ajax
* @function options
* @param {string} url - URL string
* @param {object} options - Options for the Ajax request. Refer to {@link ajax options of ajax()}.
* @example
* ajax.head('https://nhn.github.io/tui.code-snippet/v2.3.0');
*/
/**
* Send the Ajax request by HEAD
* @memberof module:ajax
* @function head
* @param {string} url - URL string
* @param {object} options - Options for the Ajax request. Refer to {@link ajax options of ajax()}.
* @example
* ajax.options('https://nhn.github.io/tui.code-snippet/v2.3.0');
*/
forEachArray(['get', 'post', 'put', 'delete', 'patch', 'options', 'head'], type => {
ajax[type] = (url, options) => ajax._request(url, type.toUpperCase(), options);
});
export default ajax;