/// namespace Leaf { declare const require: any if (typeof XMLHttpRequest == "undefined") { // pollyfill for node if (Leaf["XMLHttpRequest"]) { XMLHttpRequest = Leaf["XMLHttpRequest"] as any } else { const Q = require try { XMLHttpRequest = Q("xmlhttprequest").XMLHttpRequest; } catch (e) { XMLHttpRequest = class { constructor() { console.log("XMLHttpRequest not available in environment") } } as any } } } export interface APICallback { (error?: Error, result?: TResult): void } export interface APIFunction { (option: TOption, callback: APICallback): void API?: APIDeclare mock: (option: TOption, error?: Error, result?: TResult) => APIFunction with(data: Partial): APIFunction transform(result: (option: TResult) => TModifiedResult): ({ (option: TOption, callback: APICallback): void }) } export interface APIOption { method: string | "GET" | "POST" | "PUT" | "DELETE" path: string timeout?: number headers?: any data?: any withCredentials?: boolean bodyType?: "json" | "query" mock?: boolean } export class APIFactory { public lastResponse: string public responseHandler: { (response: string, callback: (error: Error, data: any) => void): void } = null private _handleResponse(response: string, callback: Function) { let data: { state: boolean error?: Error data: TResult } if (response) { try { if (this.option.serializer) { data = this.option.serializer.deserialize(response) } else { data = JSON.parse(response) } } catch (e) { callback(new Errors.NetworkError("Broken Response", { raw: response }), null) return } if (!data.state) { if (data.error) { callback(this.errorMapper(data.error), null) } else { callback(new Error("APIError"), null) } return } else { callback(null, data.data) } } else { callback(new Errors.NetworkError("Empty response"), null) } } constructor(public option: { root?: string prefix?: string suffix?: string defaultTimeout?: number withCredentials?: boolean headers?: any bodyType?: "json" | "query" mock?: boolean errorMapper?: (err: Error) => Error serializer?: { serialize: (data: any) => string deserialize: (data: string) => any } }) { if (!(typeof this.option.defaultTimeout === "number")) { this.option.defaultTimeout = 0 } if (!(typeof this.option.prefix === "string")) { this.option.prefix = "" } if (!(typeof this.option.suffix === "string")) { this.option.suffix = "" } if (!this.option.root) { this.option.root = "" } if (option.errorMapper) { this.errorMapper = option.errorMapper } } createAPI(option: APIOption): APIDeclare { if (typeof option.mock == "undefined") { option.mock = this.option.mock } let api = new APIDeclare(this, option) return api } createAPIFunction(option: APIOption): APIFunction { let api = this.createAPI(option) return api.getFunction() } public errorMapper: (err: Error) => Error = (err: any) => { let _err = new Error(err.message) _err.message = err.message _err.name = err.name // @ts-ignore _err.raw = err // @ts-ignore _err.state = err.state return _err } private solveResponse(err, response, callback) { callback(err, response) } public request(option: { method?: string url: string query?: any withCredentials?: boolean headers?: any timeout?: number bodyType?: "json" | "query" }, callback: { (err, result: TResult): void }) { let method = option.method || "GET" let url = option.url let sendData = null let headers = option.headers || {} let bodyType = option.bodyType || this.option.bodyType || "query" // Don't send null, send {}. Some framework not recognize null as a valid json if (!option.query) option.query = {} let queryString = this.buildQueryString(option.query) if (method.toUpperCase() === "GET") { if (url.indexOf("? <= 0")) { url += "?" } else { url += "&" } url += queryString sendData = null } else { if (bodyType === "query") { sendData = queryString } else { if (this.option.serializer) { sendData = this.option.serializer.serialize(option.query) } else { sendData = JSON.stringify(option.query) } } } let xhr = new XMLHttpRequest() xhr.open(method, url, true) if (option.withCredentials) { xhr.withCredentials = true } xhr.setRequestHeader("Accept", "application/json") if (bodyType === "json") { xhr.setRequestHeader("Content-Type", "application/json") } else { xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") } for (let name in this.option.headers) { if (!headers[name]) headers[name] = this.option.headers[name] } for (let name in headers) { xhr.setRequestHeader(name, headers[name]) } let done = false let _callback = callback let timer = null callback = (err, response: TResult) => { clearTimeout(timer) // disable multiple callback callback = () => { } this.solveResponse(err, response, _callback) return } if (option.timeout) { timer = setTimeout(() => { callback = () => { } _callback(new Errors.Timeout(`Request timeout after ${option.timeout}`), null) xhr.abort() }, option.timeout * 1000) } xhr.onreadystatechange = () => { if (xhr.readyState === 0 && !done) { callback(new Errors.NetworkError(), null) return } if (xhr.readyState === 4) { done = true this.lastResponse = xhr.responseText if (this.responseHandler) { this.responseHandler(xhr.responseText, callback) } else { this._handleResponse(xhr.responseText, callback) } } } if (method.toLowerCase() != "get") { xhr.send(sendData) } else { xhr.send() } return xhr } public buildQueryString(query) { if (!query) { return "" } let kvs = [] for (let prop in query) { let value = query[prop] || "" kvs.push([encodeURIComponent(prop), encodeURIComponent(value)].join("=")) } return kvs.join("&") } } export class APIDeclare { private mocks: { option: TOption error: Error result: TResult }[] = [] constructor(private factory: APIFactory, private option: APIOption) { } public mock(option: TOption, error?: Error, result?: TResult) { this.mocks.push({ option, error, result }) return this } private _mock(option, callback) { for (let mock of this.mocks) { if (Leaf.Util.deepEqual(option, mock.option)) { callback(mock.error, mock.result) return true } } return false } private decorateAPIOption(option, fn) { return function (...args: any[]) { let op = args[0] || {} for (let key in option) { if (typeof op[key] == "undefined") { op[key] = option[key] } } args[0] = op fn(...args) } } public getFunction(): APIFunction { let fn: APIFunction = this.invoke.bind(this) fn.API = this fn.mock = (option: TOption, error?: Error, result?: TResult) => { this.mock(option, error, result) return fn } fn.with = (data: { [K in keyof TOption]?: TOption[K] }) => { let apiOption = Leaf.Util.clone(this.option) if (!apiOption.data) apiOption.data = {} for (let key in data) { apiOption.data[key] = data[key] } let declare = new APIDeclare(this.factory, apiOption) declare.mocks = this.mocks return declare.getFunction() } fn.transform = (ts) => { return (op, callback) => { fn(op, (err, data) => { if (err) { callback() return } callback(null, ts(data)) }) } } return fn } public invoke(data: TOption = {} as any, callback: APICallback) { data = { ...data as any } if (this.option.data) { // default params for (let prop in this.option.data) { if (typeof data[prop] == "undefined") { data[prop] = this.option.data[prop] } } } if (this.option.mock || this.factory.option.mock) { let mocked = this._mock(data, callback) if (!mocked) { callback(new Error(`Mock miss for ${this.option.method} ${this.option.path}. Data: ${JSON.stringify(data)}`)) } return } let method = this.option.method let url = this.factory.option.root + this.factory.option.prefix + this.option.path + this.factory.option.suffix let routeParams = (url.match(/:[a-z_][a-z0-9_]*/ig) || []).map((item) => { return item.substring(1) }) for (let prop in data) { if (routeParams.indexOf(prop) >= 0) { url = url.replace(new RegExp(":" + prop, "g"), encodeURIComponent(data[prop] as any as string)) delete data[prop] } } let reqOption = { url, method, query: data, timeout: this.option.timeout, headers: this.option.headers, withCredentials: this.option.withCredentials, bodyType: this.option.bodyType } this.factory.request(reqOption, callback) } } }