Last active
August 9, 2021 00:44
-
-
Save MrJackdaw/ceca2b05743932513a6320f3f9eeea36 to your computer and use it in GitHub Desktop.
SPA Core | Application State and Network Manager
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import APIconfig from "./api-config"; | |
// SECTION 1: Create Your endpoints. | |
// For separation of concerns, you can create these in a separate file | |
// and import them wherever you create your `APIConfig` instance. | |
// | |
// Note: the following is entirely mocked (i.e. not a real API that | |
// I know of), but is meant to convey the idea of using `APIConfig`. | |
const MY_BASE_URL = "https://api.example.com" | |
// This is the object that will be passed to the APIConfig instance. | |
// Make sure the keys are intuitively named, since they will be converted | |
// into method names on the instance. | |
const endpoints = { | |
getUserById: { | |
// URL is required on all objects. It must be a function (with any | |
// logic and/or arguments) that returns a url string. | |
url: ({ id }) => `${MY_BASE_URL}/users/${id}`, | |
method: APIConfig.METHODS.POST, | |
}, | |
listUsers: { | |
// (Optional) You can omit the "method" key for "GET" requests | |
url: () => `${MY_BASE_URL}/users` | |
}, | |
updateUser: { | |
// "APIConfig.METHODS" contains (get, post, put, patch, delete) | |
url: ({ id }) => `${MY_BASE_URL}/users/${id}`, | |
method: APIConfig.METHODS.PATCH, | |
}, | |
uploadFile: { | |
// You can statically override some request headers here, or on a per-request | |
// basis by supplying the overrideable key in your request params | |
contentType: "multipart/form-data", | |
redirect: "follow", | |
url: () => `${MY_BASE_URL}/files/upload`, | |
method: APIConfig.METHODS.POST | |
}, | |
}; | |
// SECTION 2: Create an instance of `APIConfig`. This will be a singleton that | |
// configures and handles every outgoing request/response for your SPA. | |
// | |
// Otherwise you can just pass the endpoints to your instance: | |
const api = new APIConfig(endpoints); | |
// Now 'api' has methods that return Promises. You can use them predictably: | |
api | |
.listUsers() | |
.then(users => ... ) | |
.catch(error => ... ) | |
api | |
.getUserById({ id: ... }) | |
.then(user => ... ) | |
.catch(error => ... ) | |
api | |
.updateUser({ id: ... }) | |
.then(response => ... ) | |
.catch(error => ... ) | |
// OR | |
const [user, users] = await Promise.all([ | |
api.getUserById({ id: ... }), | |
api.listUsers(), | |
]); | |
const response = await api.updateUser({ id: ... }); | |
// THAT'S IT! If you also want the ability to handle all api errors in one place, | |
// read on. | |
// SECTION 3: (Optional) Global error handler | |
// You can supply an error-handler function to capture any failed api request. | |
// The handler must return a Promise value (either 'reject' if e.g. you want | |
// the UI to show the API error) or a fallback response if that fits your app. | |
function onGlobalError(error) { | |
console.log('Failed API Request:', error); | |
// [ your logic here e.g. log to external service ] | |
// For our example, we will still reject the error so it gets passed along | |
// to the UI | |
return Promise.reject(error); | |
} | |
// Supply endpoints AND the error-handler to your API config instance | |
const api = new APIConfig(endpoints, onGlobalError); | |
// Now if the following request fails, | |
try { | |
const user = await api.getUserById({ id: badIdDoesNotExist }); | |
} catch (e) { | |
// 'Failed API request' will be logged to the console along with the server | |
// response. You can do any additional (e.g. view-specific) error-handling here. | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import configureRoute from "./configured-route"; | |
const METHODS = { | |
POST: "POST", | |
GET: "GET", | |
DELETE: "DELETE", | |
PATCH: "PATCH", | |
PUT: "PUT", | |
}; | |
/** | |
* - Creates and configures a `Fetch` request using an APIRoute object | |
* - Triggers the request and returns the JSON from the server | |
* @param {object} routes A key-value store of server endpoints. Each key in | |
* `routes` will become a method on the new `APIConfig` instance. The value of each | |
* key will be a `RouteDefinition` with one or more of the following properties: | |
* - `acceptHeaders`: string | undefined; | |
* - `contentType`: string | undefined; | |
* - `url`: Function; | |
* - `authenticate`: boolean | undefined; | |
* - `method`: string | undefined | |
* @param {Function} globalErrorHandler An error handler to run when any network request | |
* fails. The handler will receive the (`APIConfig` instance-) rejected promise as its | |
* only argument. It should also return a promise (resolve/rejected per implementation needs). | |
* @returns {APIConfig & {[Properties in keyof routes]: (args: any) => Promise<any>}} | |
*/ | |
export default function APIConfig( | |
routes /* : { [x: string]: RouteDefinition } */, | |
globalErrorHandler = (error) => error | |
) { | |
if (!routes) { | |
throw new Error("Missing routes"); | |
} | |
if (Object.keys(routes).length === 0) { | |
throw new Error("APIConfig needs at least one valid route definition"); | |
} | |
this.routes = routes; | |
// Append route keys to object so accessibe as APIConfig.route(params).then(...); | |
Object.keys(routes).forEach((routeName) => { | |
const route = this.routes[routeName]; | |
const { group = null } = route; | |
// Group route if key present (e.g. [getById, users] -> config.users.getByid vs | |
// [getById] -> config.getById ) | |
if (group) { | |
if (!this[group]) this[group] = {}; | |
this[group][routeName] = configureRoute(route, globalErrorHandler); | |
} else { | |
this[routeName] = configureRoute(route, globalErrorHandler); | |
} | |
}); | |
return this; | |
} | |
APIConfig.METHODS = METHODS; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import createState from "./application-state.js" | |
// Create your default application state. The object you pass to | |
// `createState` will determine both the initial state of your app, | |
// as well as all setter methods you will have available (besides | |
// the instance defaults). Note that you can initialize a property | |
// as 'null' if it suits you. | |
// | |
// This state has only one property, "to-dos". | |
const myApplicationState = createState({ todos: [] }); | |
console.log(myApplicationState.getState()); // { todos: [] } | |
// add "to-dos" | |
const myToDo = { text: "Errands", done: false }; | |
myApplicationState.todos([myToDo]); | |
console.log(myApplicationState.getState()); // { todos: [{ text: "Errands", done: false }] } | |
// update "to-dos" | |
const { todos } = myApplicationState.getState(); | |
const updatedTodos = [...todos]; | |
updatedTodos[0] = { ...todos[0], done: true }; | |
myApplicationState.todos(updatedTodos); | |
console.log(myApplicationState.getState()); // { todos: [{ text: "Errands", done: true }] } | |
// reset state | |
myApplicationState.reset(); | |
console.log(myApplicationState.getState()); // { todos: [] } | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* A representation of an Application state. Taken from a public gist: | |
* see [here](https://gist.github.com/MrJackdaw/ceca2b05743932513a6320f3f9eeea36) | |
*/ | |
class ApplicationState { | |
/** | |
* State subscribers (list of listeners/functions triggered when state changes) | |
*/ | |
subscribers = []; | |
/** | |
* Application State keys and values | |
* @type {{[Properties in keyof ConstructorParameters<ApplicationState>: any }} state Application State | |
*/ | |
state = {}; | |
_ref = null; | |
/** | |
* `ApplicationState` is a class representation of the magic here. | |
* It is instantiable so a user can manage multiple subscriber groups. Every `state` key becomes | |
* a method from updating that state. (e.g. `state.users` = ApplicationState.users( ... )) | |
* @param {{[x:string]: string | number | object }} state Initial State | |
* @returns {ApplicationState & {[Properties in keyof state]: Function }} | |
*/ | |
constructor(state = {}) { | |
/* State requires at least one key */ | |
if (Object.keys(state).length < 1) { | |
const msg = | |
"'ApplicationState' needs a state value with at least one key"; | |
throw new Error(msg); | |
} | |
// Turn every key in the `state` representation into a method on the instance. | |
// This allows entire state updates by calling a single key (e.g.) `state.user({ ... })`; | |
for (let key in state) { | |
this[key] = (value) => { | |
const updated = { ...this.state, [key]: value }; | |
return updateState.apply(this, [updated, [key]]); | |
}; | |
} | |
// Initialize application state here. These is the key-value | |
// source-of-truth for your application. | |
this.state = { ...state }; | |
this._ref = Object.freeze(this.getState()); | |
return this; | |
} | |
/** Get [a copy of] the current application state */ | |
getState = () => Object.assign({}, { ...this.state }); | |
/** | |
* Update multiple keys in state before notifying subscribers. | |
* @param {object} changes Data source for state updates. This | |
* is an object with one or more state keys that need to be updated. | |
*/ | |
multiple(changes) { | |
if (typeof changes !== "object") { | |
throw new Error("State updates need to be a key-value object literal"); | |
} | |
const changeKeys = Object.keys(changes); | |
let updated = { ...this.state }; | |
changeKeys.forEach((key) => { | |
if (!this[key]) { | |
throw new Error(`There is no "${key}" in this state instance.`); | |
} else { | |
updated = { ...updated, [key]: changes[key] }; | |
} | |
}); | |
return updateState.apply(this, [updated, changeKeys]); | |
} | |
/** Reset the instance to its initialized state. Preserve subscribers. */ | |
reset() { | |
this.multiple({ ...this._ref }); | |
} | |
/** Subscribe to the state instance. Returns an `unsubscribe` function */ | |
subscribe = (listener) => { | |
// This better be a function. Or Else. | |
if (typeof listener !== "function") { | |
const msg = `Invalid listener: '${typeof listener}' is not a function`; | |
throw new Error(msg); | |
} | |
if (!this.subscribers.includes(listener)) { | |
// Add listener | |
this.subscribers.push(listener); | |
// return unsubscriber function | |
return () => this.unsubscribeListener(listener); | |
} | |
}; | |
unsubscribeListener = (listener) => { | |
const matchListener = (l) => !(l === listener); | |
return (this.subscribers = [...this.subscribers].filter(matchListener)); | |
}; | |
} | |
/** | |
* @private | |
* Update the instance with changes, then notify subscribers | |
* with a copy | |
*/ | |
function updateState(updated, updatedKeys = []) { | |
this.state = updated; | |
this.subscribers.forEach((listener) => listener(updated, updatedKeys)); | |
} | |
/** | |
* Create an `Application State` object representation. This requires | |
* a key-value state object, whose keys will be attached to setter functions | |
* on the new `Application State` instance | |
* @param {object} state State representation | |
* @returns {ApplicationState & {[Properties in keyof state]: Function }} | |
*/ | |
export default function createState(state) { | |
return new ApplicationState(state); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const METHODS = { | |
POST: "POST", | |
GET: "GET", | |
DELETE: "DELETE", | |
PATCH: "PATCH", | |
PUT: "PUT", | |
}; | |
/** | |
* A function that configures a request to a single endpoint. When called, it makes a | |
* request to the specified endpoint and returns a promise. If the request fails, it | |
* will pass the rejected response to the supplied (user-defined) `globalErrorHandler`, | |
* which can implement retry or exit logic as needed. | |
* | |
* @param {object} route Single endpoint request configuration | |
* @param {Function} route.url Function that returns request URL with any interpolation (e.g `id` in `/user/:id`) | |
* @param {string|null} route.contentType Request `content-type` header. Defaults to `application/json;charset=UTF-8;` | |
* @param {string|null} route.acceptHeaders Request `accept` header. Defaults to `* /*` | |
* @param {boolean|null} route.authenticate When true, `APIConfig` will attempt to attach a `Authorization` header, and | |
* look in the supplied (at runtime) params for a `token`. An error will be thrown if one is not found. | |
* @param {object|null} route.headers Optional request header overrides. Will be used to generate final request `headers`. | |
* @param {string|null} route.method HTTP verb (`get`, `post`, `put`, ...) Use the `METHODS` export to ensure `APIConfig` | |
* recognizes the method. | |
* @param {string|null} route.mode CORS mode. Defaults to `cors` | |
* @param {string|null} route.redirect Redirect policy (one of `manual` or `follow`) | |
* @param {(error: Object, responseCode: number, request:Request) => Promise<any>|null} globalErrorHandler Optional global | |
* error handler. Receives this from `APIConfig` instance. | |
* | |
* @returns {(params: object|undefined) => Promise<any>} Function that returns a promise. | |
*/ | |
export default function configureRoute( | |
route /* : RouteDefinition */, | |
globalErrorHandler = (error) => error | |
) { | |
/** | |
* Makes an http/s request `with` the supplied `params`. Enables the api | |
* `configInstance.route(routeParams).with(requestParams)` | |
* @param {object} params The request body contents. Anything that would be | |
* passed to a `fetch` call (except the `url` for the request) goes here. | |
* @param {string|undefined} params.token An optional bearer token for authenticating | |
* with a remote server. Required if the `ConfiguredRoute` instance contains a | |
* `authenticate: true` key/value. | |
* @param {object|undefined} params.body (optional) request `body` [required for `post` | |
* requests] | |
*/ | |
return function configuredRequest(params = {}) { | |
// Get an object ready to make a request | |
const method = route.method || METHODS.GET; | |
let url = route.url(params); | |
// Configure request | |
let reqConfig = { | |
method, | |
// Allow for setting cookies from origin | |
credentials: route.credentials || "omit", // omit, include | |
// Prevent automatic following of redirect responses (303, 30x response code) | |
redirect: route.redirect || "manual", | |
// CORS request policy | |
mode: route.mode || "cors", | |
}; | |
if (route.contentType !== "multipart/form-data") { | |
reqConfig.headers = configureReqHeaders(params, url); | |
} | |
// Configure request body | |
if (method !== METHODS.GET) { | |
reqConfig.body = configureRequestBody(params, reqConfig.headers); | |
} | |
let fetchRequest = new Request(url, reqConfig); | |
let responseCode = -1; | |
const successResponse = { message: "success" }; | |
// Return fetch Promise | |
return fetch(fetchRequest) | |
.then((data) => { | |
responseCode = data.status; | |
// If it has json, return json | |
if (data.json) return data.json() || successResponse; | |
// Safari apparently handles API "redirect" (303, 30x) responses very, very poorly; | |
// We intercept the response and return something that doesn't kill the app. | |
const isRedirectResponse = data.type === "opaqueredirect"; | |
// "DELETE" request doesn't return a body, so return "success" for that too | |
const isDeleteResponse = | |
method === METHODS.DELETE && responseCode < 400; | |
if (isRedirectResponse || isDeleteResponse) return successResponse; | |
// At this point, the response *better* have a body. Or else. | |
return data || successResponse; | |
}) | |
.then(onResponseFallback) | |
.catch(onResponseFallback); | |
function onResponseFallback(json) { | |
// Check for API failures and reject response if response status error | |
if (json.error) { | |
return globalErrorHandler( | |
Promise.reject(json), | |
responseCode, | |
new Request(url, reqConfig) | |
); | |
} | |
// Return the configured fetch request for external retry attempts | |
if (responseCode > 400 || responseCode === -1) { | |
return globalErrorHandler( | |
Promise.reject(json), | |
responseCode, | |
new Request(url, reqConfig) | |
); | |
} | |
// Else return the response since it was likely successful | |
return Promise.resolve(json || successResponse); | |
} | |
}; | |
/** | |
* Configure request headers. If `route.authenticate` is true, optionally inject | |
* an `Authorization: Bearer ...` header using an expected `token` key in `params` | |
* @param {object} params request params | |
* @param {string} url request url | |
* @returns {object} header | |
*/ | |
function configureReqHeaders(params, url) { | |
// overrides | |
const ov = { | |
...(route.headers || {}), | |
...(route.acceptHeaders || {}), | |
...(params.headers || {}), | |
}; | |
const contentType = | |
route.contentType || ov.contentType || "application/json;charset=utf-8"; | |
const headers = new Headers(); | |
headers.append("Content-Type", contentType); | |
// Inject token | |
if (route.authenticate) { | |
if (params.token) { | |
headers.append("Authorization", `Bearer ${params.token}`); | |
} else { | |
throw new Error(`Did not pass token to authenticate at url ${url}`); | |
} | |
} | |
return headers; | |
} | |
/** | |
* Configure request `body`. | |
* @param {object} params request params | |
* @param {Headers} rHeaders request headers | |
* @returns {object} header | |
*/ | |
function configureRequestBody(params, rHeaders) { | |
if (!rHeaders) { | |
throw new Error("Invalid request headers"); | |
} | |
switch (rHeaders.get("Content-Type")) { | |
case "application/x-www-form-urlencoded": | |
return generateURLEncodedBody(params.body || params); | |
default: | |
return JSON.stringify(params.body || params); | |
} | |
} | |
function generateURLEncodedBody(params) { | |
const body = new URLSearchParams(); | |
if (typeof params === "object") { | |
Object.keys(params).forEach((key) => body.append(key, params[key])); | |
} | |
return body; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Recommended directory structure: