Skip to content

Instantly share code, notes, and snippets.

@hypeJunction
Last active May 1, 2021 08:30
Show Gist options
  • Save hypeJunction/58bdfb536072cd8a927ff122ba67300d to your computer and use it in GitHub Desktop.
Save hypeJunction/58bdfb536072cd8a927ff122ba67300d to your computer and use it in GitHub Desktop.
Solid Design Systems - Data Fetching
import { RequestOptions, Transport } from "./HttpTransportProvider";
import {
createMutationRequest,
createQueryRequest,
} from "./HttpTransportService";
export class ApiClient {
readonly baseUrl: string;
readonly transport: Transport;
constructor(baseUrl: string, transport: Transport) {
this.baseUrl = baseUrl;
this.transport = transport;
}
send<T>(request: Request): Promise<T> {
return this.transport<T>(request);
}
get<T>(
url: URL,
options: Partial<Pick<RequestOptions, "headers" | "bearerToken">> = {},
method: "GET" | "HEAD" | "OPTIONS" = "GET"
): Promise<T> {
return this.send(createQueryRequest("GET", url, options));
}
post<T>(
url: URL,
options: Partial<RequestOptions> = {},
method: "POST" | "PUT" | "PATCH" | "DELETE" = "POST"
): Promise<T> {
return this.send(createMutationRequest(method, url, options));
}
}
import { ServiceProvider } from "./ServiceProvider";
import { Services, services } from "./Services";
import React from "react";
import { CocktailsController } from "./CocktailsController";
export function CocktailApp() {
return (
<ServiceProvider<Services> services={services}>
<CocktailsController />
</ServiceProvider>
);
}
import React, { createContext, ReactNode, useContext } from "react";
import { AsyncSelector, AsyncState } from "./AsyncState";
const AsyncContext = createContext<Partial<AsyncSelector<any>>>({
state: {
loading: false,
refreshing: false,
result: null,
error: null,
},
});
export type ChildNode<S extends AsyncState<any>> =
| ReactNode
| ((props: S) => ReactNode);
export interface AsyncProps<S extends AsyncSelector<any>> {
selector: S;
children: ChildNode<S["state"]>;
}
export function renderChildren<S extends AsyncState<any>>(
children: ChildNode<S>,
state: S
) {
if (typeof children === "function") {
return children(state);
}
return children;
}
export function Async<S>({ selector, children }: AsyncProps<AsyncSelector<S>>) {
return (
<AsyncContext.Provider value={selector}>
{renderChildren(children, selector.state)}
</AsyncContext.Provider>
);
}
export function AsyncLoading({ children }: { children: ChildNode<any> }) {
const { state } = useContext(AsyncContext);
return state?.loading && renderChildren(children, state);
}
export function AsyncRefreshing({ children }: { children: ChildNode<any> }) {
const { state } = useContext(AsyncContext);
return state?.refreshing && renderChildren(children, state);
}
export function AsyncError({ children }: { children: ChildNode<any> }) {
const { state } = useContext(AsyncContext);
return state?.error && renderChildren(children, state);
}
export function AsyncResult({ children }: { children: ChildNode<any> }) {
const { state } = useContext(AsyncContext);
return state?.result && renderChildren(children, state);
}
import { useCallback, useEffect, useReducer } from "react";
export type Selector<S> = (...args: Array<any>) => Promise<S>;
export interface AsyncState<S> {
result: S | null;
error: Error | null;
loading: boolean;
refreshing: boolean;
}
export interface AsyncSelector<S> {
state: AsyncState<S>;
execute: () => Promise<S | void>;
}
export function transform<T, O>(
res: Promise<T>,
transformer: (res: T) => O
): Promise<O> {
return res.then(transformer);
}
export function useAsyncCallback<S>(
fn: Selector<S>,
deps: Array<any> = []
): AsyncSelector<S> {
const [state, dispatch] = useReducer(asyncStateReducer, {
loading: false,
refreshing: false,
result: null,
error: null,
});
const execute = useCallback(() => {
dispatch({
type: "SET_LOADING",
payload: true,
});
Promise.resolve(fn(...deps))
.then((result) => {
dispatch({
type: "SET_RESULT",
payload: result,
});
})
.catch((err) => {
dispatch({
type: "SET_ERROR",
payload: err,
});
});
}, [fn, deps]);
return {
state,
execute,
} as AsyncSelector<S>;
}
export function useSelector<S>(
fn: Selector<S>,
deps: Array<any> = []
): AsyncSelector<S> {
const selector = useAsyncCallback(fn, deps);
useEffect(() => {
selector.execute();
}, deps);
return selector;
}
type AsyncStateAction<S> =
| {
type: "SET_LOADING";
payload: boolean;
}
| {
type: "SET_RESULT";
payload: S | null;
}
| {
type: "SET_ERROR";
payload: Error | null;
};
function asyncStateReducer<S>(
state: AsyncState<S>,
action: AsyncStateAction<S>
): AsyncState<S> {
switch (action.type) {
case "SET_LOADING": {
return {
...state,
loading: state.result ? false : action.payload,
refreshing: state.result ? action.payload : false,
};
}
case "SET_RESULT": {
return {
...state,
result: action.payload,
loading: false,
refreshing: false,
};
}
case "SET_ERROR": {
return {
...state,
error: action.payload,
loading: false,
refreshing: false,
};
}
}
}
import { Filters } from "./QueryBuilder";
import React from "react";
export function CocktailsFilter({
filters,
onChange,
}: {
filters: Filters;
onChange: (filters: Filters) => void;
}) {
const categories = ["Cocktail", "Ordinary Drink", "Shot"];
return (
<div>
{categories.map((c) => {
return (
<button
key={c}
onClick={() => onChange({ c })}
disabled={filters.c === c}
>
{c}
</button>
);
})}
</div>
);
}
import React from "react";
import { Cocktail } from "./CocktailsController";
export function CocktailCard({ cocktail }: { cocktail: Cocktail }) {
return (
<div>
<img src={cocktail.image} alt={cocktail.name} />
<h3>{cocktail.name}</h3>
</div>
);
}
import React from "react";
import { Cocktail } from "./CocktailsController";
import { CocktailCard } from "./CocktailCard";
export function CocktailList({ items }: { items: Array<Cocktail> }) {
return (
<div>
{items.map((item) => (
<CocktailCard key={item.id} cocktail={item} />
))}
</div>
);
}
import { act, render } from "@testing-library/react";
import { ServiceProvider } from "./ServiceProvider";
import { SinonSpy, stub } from "sinon";
import { Services, services as defaultServices } from "./Services";
import { ApiClient } from "./ApiClient";
import { CocktailsController } from "./CocktailsController";
import { expect } from "chai";
import { Transport } from "./HttpTransportProvider";
import { CocktailDto } from "./CocktailsDb";
describe("CocktailsController", () => {
describe("given a pending request", () => {
it("should display a loading state", () => {
const promise = new Promise(() => {});
const fetcher: Transport & SinonSpy = stub().returns(promise);
const apiClient = new ApiClient("http://example.com", fetcher);
const services = {
...defaultServices,
cocktailsDb: () => apiClient,
};
const { getByText } = render(
<ServiceProvider<Services> services={services}>
<CocktailsController />
</ServiceProvider>
);
expect(getByText("Loading...")).to.be.visible;
});
});
describe("given a failed request", () => {
it("should display an error state", async () => {
const error = new Error("Failed request");
const fetcher: Transport & SinonSpy = stub().returns(
Promise.reject(error)
);
const apiClient = new ApiClient("http://example.com", fetcher);
const services = {
...defaultServices,
cocktailsDb: () => apiClient,
};
const { getByText } = render(
<ServiceProvider<Services> services={services}>
<CocktailsController />
</ServiceProvider>
);
try {
await act(async () => await fetcher());
} catch (err) {
expect(err).to.equal(error);
}
expect(getByText(error.message)).to.be.visible;
});
});
describe("given a successful request", () => {
it("should display a list", async () => {
const error = new Error("Failed request");
const fetcher: Transport & SinonSpy = stub().returns(
Promise.resolve({
drinks: [
{
strDrink: "Test Cocktail",
strDrinkThumb: "image.jpg",
idDrink: "test-cocktail",
},
],
} as { drinks: Array<CocktailDto> })
);
const apiClient = new ApiClient("http://example.com", fetcher);
const services = {
...defaultServices,
cocktailsDb: () => apiClient,
};
const { getByText } = render(
<ServiceProvider<Services> services={services}>
<CocktailsController />
</ServiceProvider>
);
await act(async () => await fetcher());
expect(getByText("Test Cocktail")).to.be.visible;
});
});
});
import { CocktailDto, CocktailsService, fetchCocktails } from "./CocktailsDb";
import { CollectionQuery, useQueryBuilder } from "./QueryBuilder";
import { useServiceProvider } from "./ServiceProvider";
import { transform, useSelector } from "./AsyncState";
import {
Async,
AsyncError,
AsyncLoading,
AsyncRefreshing,
AsyncResult,
} from "./Async";
import { CocktailsFilter } from "./CockitalsFilter";
import { CocktailList } from "./CocktailList";
import React from "react";
export interface Cocktail {
name: string;
image: string;
id: string;
}
export function cocktailMapper(e: CocktailDto): Cocktail {
return {
id: e.idDrink,
name: e.strDrink,
image: e.strDrinkThumb,
};
}
export function useCocktails(query: CollectionQuery) {
const { cocktailsDb } = useServiceProvider<CocktailsService>();
return useSelector(
() =>
transform(fetchCocktails(cocktailsDb, query), (res) =>
res.drinks.map(cocktailMapper)
),
[query]
);
}
export function CocktailsController() {
const { query, setFilters } = useQueryBuilder({
filters: {
c: "Cocktail",
},
});
const selector = useCocktails(query);
return (
<Async selector={selector}>
<AsyncLoading>Loading...</AsyncLoading>
<AsyncRefreshing>Refreshing...</AsyncRefreshing>
<AsyncError>{(state) => state.error.message}</AsyncError>
<AsyncResult>
{(state) => (
<>
<CocktailsFilter filters={query.filters} onChange={setFilters} />
<CocktailList items={state.result} />
</>
)}
</AsyncResult>
</Async>
);
}
import { buildUrl } from "./HttpTransportService";
import { CollectionQuery } from "./QueryBuilder";
import { Identity } from "./HttpTransportProvider";
import { ServiceFactoryFn, ServiceFactoryMap } from "./ServiceProvider";
import { ApiClient } from "./ApiClient";
export type CocktailsDb = ApiClient;
export interface CocktailsService extends ServiceFactoryMap {
cocktailsDb: ServiceFactoryFn<CocktailsDb>;
}
export interface CocktailDto {
strDrink: string;
strDrinkThumb: string;
idDrink: string;
}
export const fetchCocktails = (
client: ApiClient,
query: CollectionQuery,
identity?: Identity
): Promise<{ drinks: Array<CocktailDto> }> => {
return client.get(
buildUrl(client.baseUrl, "/v1/1/filter.php", { ...query.filters }),
{
bearerToken: identity?.bearerToken,
}
);
};
import { ServiceFactoryFn, ServiceFactoryMap } from "./ServiceProvider";
export type Transport = <T>(request: Request) => Promise<T>;
export type BearerToken = string | undefined;
export type Header = Record<string, string>;
export interface RequestOptions {
bearerToken: BearerToken;
headers: Header;
body: BodyInit | null | undefined;
}
export interface Identity {
bearerToken: BearerToken;
}
export interface IdentityService extends ServiceFactoryMap {
identity: ServiceFactoryFn<Identity>;
}
import {
BearerToken,
Header,
RequestOptions,
Transport,
} from "./HttpTransportProvider";
export function createQueryRequest(
method: "GET" | "HEAD" | "OPTIONS",
url: URL,
options: Partial<Pick<RequestOptions, "headers" | "bearerToken">> = {}
) {
return new Request(url.toString(), {
method,
headers: buildHttpHeaders(options.bearerToken, options.headers || {}),
});
}
export function createMutationRequest(
method: "POST" | "PUT" | "PATCH" | "DELETE",
url: URL,
options: Partial<RequestOptions> = {}
) {
return new Request(url.toString(), {
method,
cache: "no-cache",
headers: buildHttpHeaders(options.bearerToken, options.headers || {}),
body: options.body,
});
}
export const send: Transport = <T>(request: Request): Promise<T> => {
return fetch(request).then((res) => parseFetchResponse<T>(res));
};
export const parseFetchResponse = async <T>(response: Response): Promise<T> => {
const getData = async () => {
try {
return await response.json();
} catch (err) {
return {};
}
};
const data = await getData();
if (response.ok && !data?.errors) {
return Promise.resolve(data);
}
throw new HttpError(response.status || 500, data || {});
};
// Modified from https://stackoverflow.com/a/42604801
export function serializeUrlSearchParams(
params: { [key: string]: any },
prefix?: string
): string {
const query = Object.keys(params).map((key) => {
const value = params[key];
if (params.constructor === Array) {
key = `${prefix}[]`;
} else if (params.constructor === Object) {
key = prefix ? `${prefix}[${key}]` : key;
}
if (typeof value === "object") {
return serializeUrlSearchParams(value, key);
} else {
return `${key}=${encodeURIComponent(value)}`;
}
});
return query.join("&");
}
export function buildUrl(
baseUrl: string,
endpoint: string,
query: object = {}
): URL {
const queryString = serializeUrlSearchParams(query);
const glue =
endpoint.indexOf("?") === -1 && queryString.length > 0 ? "?" : "";
return new URL(`${baseUrl}${endpoint}${glue}${queryString}`);
}
export const buildHttpHeaders = (
bearerToken: BearerToken,
...parts: Array<Header>
): Headers => {
return new Headers(
Object.assign(
{},
bearerToken && {
Authorization: `Bearer ${bearerToken}`,
},
...parts
)
);
};
export function buildPostRequestOptions(
format: "json" | "FormData",
payload: object
): Pick<RequestOptions, "headers" | "body"> {
switch (format) {
case "json": {
return {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: payload ? JSON.stringify(payload) : null,
};
}
case "FormData": {
const fd = new FormData();
Object.entries(payload).forEach(([value, key]) => {
fd.append(key, value);
});
return {
headers: {},
body: fd,
};
}
}
}
export class HttpError implements Error {
name: string;
message: string;
readonly code: string | number;
readonly payload: object;
constructor(code: string | number, payload: object) {
this.code = code;
this.payload = payload;
this.name = "HttpError";
this.message = `Error Response [${code}]: ${JSON.stringify(payload)}`;
}
}
import React from "react";
import ReactDOM from "react-dom";
import { CocktailApp } from "./App";
ReactDOM.render(
<React.StrictMode>
<CocktailApp />
</React.StrictMode>,
document.getElementById("root")
);
import { Reducer, useCallback, useReducer, useRef } from "react";
export type Filters = Record<string, any>;
export type Sorts = Record<string, "asc" | "desc">;
export interface CollectionQuery {
filters: Filters | null;
sorts: Sorts | null;
page: number | null;
perPage: number | null;
}
type QueryBuilderAction =
| {
type: "SET_FILTERS";
payload: Filters;
}
| {
type: "SET_SORTS";
payload: Sorts;
}
| {
type: "SET_PAGE";
payload: number;
}
| {
type: "SET_PER_PAGE";
payload: number;
};
function queryBuilderActionReducer(
query: CollectionQuery,
action: QueryBuilderAction
): CollectionQuery {
switch (action.type) {
case "SET_FILTERS": {
return {
...query,
filters: action.payload,
};
}
case "SET_SORTS": {
return {
...query,
sorts: action.payload,
};
}
case "SET_PAGE": {
return {
...query,
page: action.payload,
};
}
case "SET_PER_PAGE": {
return {
...query,
perPage: action.payload,
};
}
}
}
export function useResettableReducer(
reducer: Reducer<any, any>,
initialState: any
) {
const { current: initial } = useRef(initialState);
const resettableReducer = useCallback(
(state, action) => {
if (action.type === "RESET") {
return initial;
}
return reducer(state, action);
},
[reducer, initial]
);
return useReducer(resettableReducer, initialState);
}
export function useQueryBuilder(initialState: Partial<CollectionQuery>) {
const [query, dispatch] = useResettableReducer(queryBuilderActionReducer, {
perPage: null,
page: null,
sorts: null,
filters: null,
...initialState,
} as CollectionQuery);
return {
query,
setFilters: (payload: Filters) =>
dispatch({
type: "SET_FILTERS",
payload,
}),
setSorts: (payload: Sorts) =>
dispatch({
type: "SET_SORTS",
payload,
}),
setPage: (payload: number) =>
dispatch({
type: "SET_PAGE",
payload,
}),
setPerPage: (payload: number) =>
dispatch({
type: "SET_PER_PAGE",
payload,
}),
reset: () => {
dispatch({
type: "RESET",
payload: null,
});
},
};
}
import { createContext, ReactNode, useContext, useRef } from "react";
export type Container<S extends ServiceFactoryMap> = {
[P in keyof S]: ReturnType<S[P]>;
};
export type ServiceFactoryFn<S> = (container: Container<any>) => S;
export type ServiceFactoryMap = Record<string | symbol, ServiceFactoryFn<any>>;
const ContainerContext = createContext<
Partial<{ services: Container<ServiceFactoryMap> }>
>({});
interface ServiceProviderProps<S extends ServiceFactoryMap> {
services: S;
children: ReactNode;
}
export function ServiceProvider<S extends ServiceFactoryMap>({
services,
children,
}: ServiceProviderProps<S>) {
const setup = (): Container<S> => {
const singletons: Partial<Container<S>> = {};
return new Proxy(services, {
get(instance: S, property: keyof S, receiver: any) {
if (!singletons?.[property]) {
singletons[property] = instance[property](receiver);
}
return singletons[property];
},
}) as Container<S>;
};
const { current } = useRef(setup());
return (
<ContainerContext.Provider value={{ services: current }}>
{children}
</ContainerContext.Provider>
);
}
export function useServiceProvider<
S extends ServiceFactoryMap
>(): Container<S> {
return useContext(ContainerContext).services as Container<S>;
}
import {
Container,
ServiceFactoryFn,
ServiceFactoryMap,
} from "./ServiceProvider";
import { send } from "./HttpTransportService";
import { CocktailsDb, CocktailsService } from "./CocktailsDb";
import { ApiClient } from "./ApiClient";
const config = {
cocktailsDbBaseUrl:
process.env.COCKTAILS_DB_BASE_URL ||
"https://www.thecocktaildb.com/api/json",
};
export interface ConfigService extends ServiceFactoryMap {
config: ServiceFactoryFn<typeof config>;
}
export type Services = ConfigService & CocktailsService;
export const services: Services = {
config: () => config,
cocktailsDb: (c: Container<Services>) =>
new ApiClient(c.config.cocktailsDbBaseUrl, send) as CocktailsDb,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment