import EventEmitter from "eventemitter3" import io from "socket.io-client" import { AsyncStorage } from "react-native" import autobind from "autobind-decorator" import { localIPAddr } from "./development" const authTokenKeyName = "AuthToken" const backendKeyName = "Backend" class NetworkError extends Error { constructor(message) { super(message) this.name = this.constructor.name if (typeof Error.captureStackTrace === "function") { Error.captureStackTrace(this, this.constructor) } else { this.stack = new Error(message).stack } } } class APIError extends Error { constructor(status, message) { super(message || "") this.status = status || 500 this.name = this.constructor.name if (typeof Error.captureStackTrace === "function") { Error.captureStackTrace(this, this.constructor) } else { this.stack = new Error(message).stack } } } @autobind class API extends EventEmitter { static urls = { normal: "https://dar.kss.us.com/api", test: "https://dar-test.kss.us.com/api", local: `http://${localIPAddr || "localhost"}:3001`, } constructor() { super() this.user = { pending: true } this._apiURL = null const checkForToken = () => { AsyncStorage.getItem(authTokenKeyName) .then((token) => { if (!token) { return Promise.reject() } this.token = token return this.who() }) .then((user) => { this.user = user this.connectSocket() this.emit("login") }) .catch(() => { AsyncStorage.removeItem(authTokenKeyName) this.token = null this.user = {} this.socket = null this.emit("logout") }) } AsyncStorage.getItem(backendKeyName) .then((backend) => { this._backend = backend this.apiURL = API.urls[backend] if (!this.apiURL) { return Promise.reject() } checkForToken() }) .catch(() => { this._backend = "normal" this.apiURL = API.urls[this._backend] AsyncStorage.setItem(backendKeyName, this._backend) checkForToken() }) } connectSocket() { this.socket = io(this._baseURL, { path: this.apiPath + "/socketio", query: { auth_token: this.token, }, }) this.socket.on("disconnect", (reason) => { // Could happen if the auth_token is bad this.socket = null }) this.socket.on("notify", (message) => { const { eventName, eventData } = message // Filter the few massages that affect our cached user data to avoid a server round trip switch (eventName) { default: // Nothing to see here... break } this.emit(message.eventName, message.eventData) }) } disconnectSocket() { if (this.socket) { this.socket.disconnect() this.socket = null } } get loggedInUser() { return this.user } get apiURL() { return this._apiURL } set apiURL(url) { if (url) { const parts = url.split("/") if (parts.length < 2) { throw new Error("Invalid API URL") } this._apiURL = url this._baseURL = parts[0] + "//" + parts[1] this._secure = parts[0] === "https:" if (parts.length === 3) { this._apiPath = "/" + parts[2] } else { this._apiPath = "" } } } get baseURL() { return this._baseURL } get apiPath() { return this._apiPath } get secure() { return this._secure } get backend() { return this._backend } set backend(backend) { if (this._backend !== backend) { const newBaseURL = API.urls[backend] if (newBaseURL) { this.apiURL = newBaseURL this._backend = backend AsyncStorage.setItem(backendKeyName, this._backend).then( this.logout, this.logout ) } } } makeImageUrl(id, size) { if (id) { return this.apiPath + "/assets/" + id + "?access_token=" + this.token } else if (size && size.width && size.height) { return `${this.apiPath}/placeholders/${size.width}x${ size.height }?access_token=${this.token}` } else { return null } } makeAssetUrl(id) { return id ? this.apiPath + "/assets/" + id + "?access_token=" + this.token : null } static makeParams(params) { return params ? "?" + Object.keys(params) .map((key) => [key, params[key]].map(encodeURIComponent).join("=")) .join("&") : "" } request(method, path, requestBody, requestOptions) { requestOptions = requestOptions || {} var promise = new Promise((resolve, reject) => { let fetchOptions = { method: method, mode: "cors", cache: "no-store", } let headers = new Headers() if (this.token) { headers.set("Authorization", "Bearer " + this.token) } if (method === "POST" || method === "PUT") { if (requestOptions.binary) { headers.set("Content-Type", "application/octet-stream") headers.set("Content-Length", requestOptions.binary.length) headers.set("Range", "byte " + requestOptions.binary.offset) fetchOptions.body = requestBody } else { headers.set("Content-Type", "application/json") fetchOptions.body = JSON.stringify(requestBody) } } fetchOptions.headers = headers if (!this.apiURL) { return reject(new Error("API layer not ready")) } fetch(this.apiURL + path, fetchOptions) .then((res) => { return Promise.all([ Promise.resolve(res), requestOptions.binary && method === "GET" ? res.blob() : res.json(), ]) }) .then((arr) => { let [res, responseBody] = arr if (res.ok) { if (requestOptions.wantHeaders) { resolve({ body: responseBody, headers: res.headers }) } else { resolve(responseBody) } } else { reject(new APIError(res.status, responseBody.message)) } }) .catch((error) => { reject(new NetworkError(error.message)) }) }) return promise } post(path, requestBody, options) { return this.request("POST", path, requestBody, options) } put(path, requestBody, options) { return this.request("PUT", path, requestBody, options) } get(path, options) { return this.request("GET", path, options) } delete(path, options) { return this.request("DELETE", path, options) } login(email, password, remember) { return new Promise((resolve, reject) => { this.post("/auth/login", { email, password }, { wantHeaders: true }) .then((response) => { // Save bearer token for later use const authValue = response.headers.get("Authorization") const [scheme, token] = authValue.split(" ") if (scheme !== "Bearer" || !token) { reject(new APIError("Unexpected Authorization scheme or token")) } if (remember) { AsyncStorage.setItem(authTokenKeyName, token) } this.token = token this.user = response.body this.connectSocket() this.emit("login") resolve(response.body) }) .catch((err) => { reject(err) }) }) } logout() { let cb = () => { // Regardless of response, always logout in the client AsyncStorage.removeItem(authTokenKeyName) this.token = null this.user = {} this.disconnectSocket() this.emit("logout") } return this.delete("/auth/login").then(cb, cb) } who() { return this.get("/auth/who") } getUser(_id) { return this.get("/users/" + _id) } listUsers() { return this.get("/users") } createUser(user) { return this.post("/users", user) } updateUser(user) { return new Promise((resolve, reject) => { this.put("/users", user) .then((user) => { // If we just updated ourselves, update the internal cached copy if (user._id === this.user._id) { this.user = user this.emit("login") } resolve(user) }) .catch((reason) => { reject(reason) }) }) } deleteUser(_id) { return this.delete("/users/" + _id) } enterRoom(roomName) { return this.put("/users/enter-room/" + (roomName || "")) } leaveRoom() { return this.put("/users/leave-room") } getWorkItem(_id) { return this.get("/workitems/" + _id) } listWorkItems() { return this.get("/workitems") } listWorkItemActivities() { return this.get("/workitems/activities") } createWorkItem(workItem) { return this.post("/workitems", workItem) } updateWorkItem(workItem) { return this.put("/workitems", workItem) } deleteWorkItem(_id) { return this.delete("/workitems/" + _id) } getActivity(_id) { return this.get("/activities/" + _id) } listActivities() { return this.get("/activities") } createActivity(activity) { return this.post("/activities", activity) } updateActivity(activity) { return this.put("/activities", activity) } deleteActivity(_id) { return this.delete("/activities/" + _id) } upload(file, progressCallback) { return new Promise((resolve, reject) => { const chunkSize = 32 * 1024 let reader = new FileReader() const fileSize = file.size const numberOfChunks = Math.ceil(fileSize / chunkSize) let chunk = 0 let uploadId = null reader.onload = (e) => { const buffer = e.target.result const bytesRead = buffer.byteLength this.post("/assets/upload/" + uploadId, buffer, { binary: { offset: chunk * chunkSize, length: bytesRead }, }) .then((uploadData) => { chunk++ if (!progressCallback(uploadData)) { return Promise.reject(new Error("Upload was canceled")) } if (chunk < numberOfChunks) { let start = chunk * chunkSize let end = Math.min(fileSize, start + chunkSize) reader.readAsArrayBuffer(file.slice(start, end)) } else { resolve(uploadData) } }) .catch((err) => { reject(err) }) } this.post("/assets/upload", { fileName: file.name, fileSize, contentType: file.type, numberOfChunks, }) .then((uploadData) => { uploadId = uploadData.uploadId reader.readAsArrayBuffer(file.slice(0, chunkSize)) }) .catch((err) => { reject(err) }) }) } } export let api = new API()