Skip to content

Instantly share code, notes, and snippets.

@MrJackdaw
Last active August 9, 2021 00:44
Show Gist options
  • Save MrJackdaw/ceca2b05743932513a6320f3f9eeea36 to your computer and use it in GitHub Desktop.
Save MrJackdaw/ceca2b05743932513a6320f3f9eeea36 to your computer and use it in GitHub Desktop.
SPA Core | Application State and Network Manager
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.
}
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;
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: [] }
/**
* 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);
}
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;
}
}
@MrJackdaw
Copy link
Author

Recommended directory structure:

- state 
   |- /application-state.js
   |- /index.js (see application-state.example.js)

- api
   |- /api-config.js
   |- /index.js (see api-config.example.js)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment