///
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)
}
}
}