From 5f4611d65e40eae3ca6191a15f68d69ea5a1c4cb Mon Sep 17 00:00:00 2001 From: Kirill Rogovoy Date: Tue, 20 Jul 2021 21:24:52 +0300 Subject: WIP --- app_expo/services/api/api-config.ts | 27 ++++ app_expo/services/api/api-problem.test.ts | 100 ++++++++++++ app_expo/services/api/api-problem.ts | 74 +++++++++ app_expo/services/api/api.ts | 102 ++++++++++++ app_expo/services/api/api.types.ts | 13 ++ app_expo/services/api/character-api.ts | 37 +++++ app_expo/services/api/index.ts | 2 + app_expo/services/reactotron/index.ts | 1 + app_expo/services/reactotron/reactotron-config.ts | 30 ++++ app_expo/services/reactotron/reactotron.ts | 185 ++++++++++++++++++++++ app_expo/services/reactotron/tron.ts | 2 + app_expo/services/reactotron/tron.web.ts | 2 + 12 files changed, 575 insertions(+) create mode 100644 app_expo/services/api/api-config.ts create mode 100644 app_expo/services/api/api-problem.test.ts create mode 100644 app_expo/services/api/api-problem.ts create mode 100644 app_expo/services/api/api.ts create mode 100644 app_expo/services/api/api.types.ts create mode 100644 app_expo/services/api/character-api.ts create mode 100644 app_expo/services/api/index.ts create mode 100644 app_expo/services/reactotron/index.ts create mode 100644 app_expo/services/reactotron/reactotron-config.ts create mode 100644 app_expo/services/reactotron/reactotron.ts create mode 100644 app_expo/services/reactotron/tron.ts create mode 100644 app_expo/services/reactotron/tron.web.ts (limited to 'app_expo/services') diff --git a/app_expo/services/api/api-config.ts b/app_expo/services/api/api-config.ts new file mode 100644 index 0000000..b939c0e --- /dev/null +++ b/app_expo/services/api/api-config.ts @@ -0,0 +1,27 @@ +// Use this import if you want to use "env.js" file +// const { API_URL } = require("../../config/env") +// Or just specify it directly like this: +const API_URL = 'http://example.com' + +/** + * The options used to configure the API. + */ +export interface ApiConfig { + /** + * The URL of the api. + */ + url: string + + /** + * Milliseconds before we timeout the request. + */ + timeout: number +} + +/** + * The default configuration for the app. + */ +export const DEFAULT_API_CONFIG: ApiConfig = { + url: API_URL || 'https://jsonplaceholder.typicode.com', + timeout: 10000, +} diff --git a/app_expo/services/api/api-problem.test.ts b/app_expo/services/api/api-problem.test.ts new file mode 100644 index 0000000..f8b4041 --- /dev/null +++ b/app_expo/services/api/api-problem.test.ts @@ -0,0 +1,100 @@ +import { getGeneralApiProblem } from './api-problem' +import { ApiErrorResponse } from 'apisauce' + +test('handles connection errors', () => { + expect( + getGeneralApiProblem({ + problem: 'CONNECTION_ERROR', + } as ApiErrorResponse), + ).toEqual({ + kind: 'cannot-connect', + temporary: true, + }) +}) + +test('handles network errors', () => { + expect( + getGeneralApiProblem({ + problem: 'NETWORK_ERROR', + } as ApiErrorResponse), + ).toEqual({ + kind: 'cannot-connect', + temporary: true, + }) +}) + +test('handles timeouts', () => { + expect( + getGeneralApiProblem({ + problem: 'TIMEOUT_ERROR', + } as ApiErrorResponse), + ).toEqual({ + kind: 'timeout', + temporary: true, + }) +}) + +test('handles server errors', () => { + expect(getGeneralApiProblem({ problem: 'SERVER_ERROR' } as ApiErrorResponse)).toEqual({ + kind: 'server', + }) +}) + +test('handles unknown errors', () => { + expect( + getGeneralApiProblem({ + problem: 'UNKNOWN_ERROR', + } as ApiErrorResponse), + ).toEqual({ + kind: 'unknown', + temporary: true, + }) +}) + +test('handles unauthorized errors', () => { + expect( + getGeneralApiProblem({ + problem: 'CLIENT_ERROR', + status: 401, + } as ApiErrorResponse), + ).toEqual({ + kind: 'unauthorized', + }) +}) + +test('handles forbidden errors', () => { + expect( + getGeneralApiProblem({ + problem: 'CLIENT_ERROR', + status: 403, + } as ApiErrorResponse), + ).toEqual({ + kind: 'forbidden', + }) +}) + +test('handles not-found errors', () => { + expect( + getGeneralApiProblem({ + problem: 'CLIENT_ERROR', + status: 404, + } as ApiErrorResponse), + ).toEqual({ + kind: 'not-found', + }) +}) + +test('handles other client errors', () => { + expect( + getGeneralApiProblem({ + problem: 'CLIENT_ERROR', + status: 418, + } as ApiErrorResponse), + ).toEqual({ + kind: 'rejected', + }) +}) + +test('handles cancellation errors', () => { + expect(getGeneralApiProblem({ problem: 'CANCEL_ERROR' } as ApiErrorResponse)).toBeNull() +}) diff --git a/app_expo/services/api/api-problem.ts b/app_expo/services/api/api-problem.ts new file mode 100644 index 0000000..15ca850 --- /dev/null +++ b/app_expo/services/api/api-problem.ts @@ -0,0 +1,74 @@ +import { ApiResponse } from 'apisauce' + +export type GeneralApiProblem = + /** + * Times up. + */ + | { kind: 'timeout'; temporary: true } + /** + * Cannot connect to the server for some reason. + */ + | { kind: 'cannot-connect'; temporary: true } + /** + * The server experienced a problem. Any 5xx error. + */ + | { kind: 'server' } + /** + * We're not allowed because we haven't identified ourself. This is 401. + */ + | { kind: 'unauthorized' } + /** + * We don't have access to perform that request. This is 403. + */ + | { kind: 'forbidden' } + /** + * Unable to find that resource. This is a 404. + */ + | { kind: 'not-found' } + /** + * All other 4xx series errors. + */ + | { kind: 'rejected' } + /** + * Something truly unexpected happened. Most likely can try again. This is a catch all. + */ + | { kind: 'unknown'; temporary: true } + /** + * The data we received is not in the expected format. + */ + | { kind: 'bad-data' } + +/** + * Attempts to get a common cause of problems from an api response. + * + * @param response The api response. + */ +export function getGeneralApiProblem(response: ApiResponse): GeneralApiProblem | void { + switch (response.problem) { + case 'CONNECTION_ERROR': + return { kind: 'cannot-connect', temporary: true } + case 'NETWORK_ERROR': + return { kind: 'cannot-connect', temporary: true } + case 'TIMEOUT_ERROR': + return { kind: 'timeout', temporary: true } + case 'SERVER_ERROR': + return { kind: 'server' } + case 'UNKNOWN_ERROR': + return { kind: 'unknown', temporary: true } + case 'CLIENT_ERROR': + switch (response.status) { + case 401: + return { kind: 'unauthorized' } + case 403: + return { kind: 'forbidden' } + case 404: + return { kind: 'not-found' } + default: + return { kind: 'rejected' } + } + case 'CANCEL_ERROR': + return null + } + + return null +} diff --git a/app_expo/services/api/api.ts b/app_expo/services/api/api.ts new file mode 100644 index 0000000..4093d34 --- /dev/null +++ b/app_expo/services/api/api.ts @@ -0,0 +1,102 @@ +import { ApisauceInstance, create, ApiResponse } from 'apisauce' +import { getGeneralApiProblem } from './api-problem' +import { ApiConfig, DEFAULT_API_CONFIG } from './api-config' +import * as Types from './api.types' + +/** + * Manages all requests to the API. + */ +export class Api { + /** + * The underlying apisauce instance which performs the requests. + */ + apisauce: ApisauceInstance + + /** + * Configurable options. + */ + config: ApiConfig + + /** + * Creates the api. + * + * @param config The configuration to use. + */ + constructor(config: ApiConfig = DEFAULT_API_CONFIG) { + this.config = config + } + + /** + * Sets up the API. This will be called during the bootup + * sequence and will happen before the first React component + * is mounted. + * + * Be as quick as possible in here. + */ + setup() { + // construct the apisauce instance + this.apisauce = create({ + baseURL: this.config.url, + timeout: this.config.timeout, + headers: { + Accept: 'application/json', + }, + }) + } + + /** + * Gets a list of users. + */ + async getUsers(): Promise { + // make the api call + const response: ApiResponse = await this.apisauce.get(`/users`) + + // the typical ways to die when calling an api + if (!response.ok) { + const problem = getGeneralApiProblem(response) + if (problem) return problem + } + + const convertUser = (raw) => { + return { + id: raw.id, + name: raw.name, + } + } + + // transform the data into the format we are expecting + try { + const rawUsers = response.data + const resultUsers: Types.User[] = rawUsers.map(convertUser) + return { kind: 'ok', users: resultUsers } + } catch { + return { kind: 'bad-data' } + } + } + + /** + * Gets a single user by ID + */ + + async getUser(id: string): Promise { + // make the api call + const response: ApiResponse = await this.apisauce.get(`/users/${id}`) + + // the typical ways to die when calling an api + if (!response.ok) { + const problem = getGeneralApiProblem(response) + if (problem) return problem + } + + // transform the data into the format we are expecting + try { + const resultUser: Types.User = { + id: response.data.id, + name: response.data.name, + } + return { kind: 'ok', user: resultUser } + } catch { + return { kind: 'bad-data' } + } + } +} diff --git a/app_expo/services/api/api.types.ts b/app_expo/services/api/api.types.ts new file mode 100644 index 0000000..21d3c87 --- /dev/null +++ b/app_expo/services/api/api.types.ts @@ -0,0 +1,13 @@ +import { GeneralApiProblem } from './api-problem' +import { Character } from '../../models/character/character' + +export interface User { + id: number + name: string +} + +export type GetUsersResult = { kind: 'ok'; users: User[] } | GeneralApiProblem +export type GetUserResult = { kind: 'ok'; user: User } | GeneralApiProblem + +export type GetCharactersResult = { kind: 'ok'; characters: Character[] } | GeneralApiProblem +export type GetCharacterResult = { kind: 'ok'; character: Character } | GeneralApiProblem diff --git a/app_expo/services/api/character-api.ts b/app_expo/services/api/character-api.ts new file mode 100644 index 0000000..e28ec48 --- /dev/null +++ b/app_expo/services/api/character-api.ts @@ -0,0 +1,37 @@ +import { ApiResponse } from 'apisauce' +import { Api } from './api' +import { GetCharactersResult } from './api.types' +import { getGeneralApiProblem } from './api-problem' + +const API_PAGE_SIZE = 50 + +export class CharacterApi { + private api: Api + + constructor(api: Api) { + this.api = api + } + + async getCharacters(): Promise { + try { + // make the api call + const response: ApiResponse = await this.api.apisauce.get( + 'https://raw.githubusercontent.com/infinitered/ignite/master/data/rick-and-morty.json', + { amount: API_PAGE_SIZE }, + ) + + // the typical ways to die when calling an api + if (!response.ok) { + const problem = getGeneralApiProblem(response) + if (problem) return problem + } + + const characters = response.data.results + + return { kind: 'ok', characters } + } catch (e) { + __DEV__ && console.tron.log(e.message) + return { kind: 'bad-data' } + } + } +} diff --git a/app_expo/services/api/index.ts b/app_expo/services/api/index.ts new file mode 100644 index 0000000..f133e64 --- /dev/null +++ b/app_expo/services/api/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './api.types' diff --git a/app_expo/services/reactotron/index.ts b/app_expo/services/reactotron/index.ts new file mode 100644 index 0000000..0c02f1f --- /dev/null +++ b/app_expo/services/reactotron/index.ts @@ -0,0 +1 @@ +export * from './reactotron' diff --git a/app_expo/services/reactotron/reactotron-config.ts b/app_expo/services/reactotron/reactotron-config.ts new file mode 100644 index 0000000..40e9c93 --- /dev/null +++ b/app_expo/services/reactotron/reactotron-config.ts @@ -0,0 +1,30 @@ +export interface ReactotronConfig { + /** The name of the app. */ + name?: string + /** The host to connect to: default 'localhost'. */ + host?: string + /** Should we use async storage */ + useAsyncStorage?: boolean + /** Should we clear Reactotron when load? */ + clearOnLoad?: boolean + /** Root state logging. */ + state?: { + /** log the initial data that we put into the state on startup? */ + initial?: boolean + /** log snapshot changes. */ + snapshots?: boolean + } +} + +/** + * The default Reactotron configuration. + */ +export const DEFAULT_REACTOTRON_CONFIG: ReactotronConfig = { + clearOnLoad: true, + host: 'localhost', + useAsyncStorage: true, + state: { + initial: true, + snapshots: false, + }, +} diff --git a/app_expo/services/reactotron/reactotron.ts b/app_expo/services/reactotron/reactotron.ts new file mode 100644 index 0000000..6a20a75 --- /dev/null +++ b/app_expo/services/reactotron/reactotron.ts @@ -0,0 +1,185 @@ +import { Tron } from './tron' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { RootStore } from '../../models/root-store/root-store' +import { onSnapshot } from 'mobx-state-tree' +import { ReactotronConfig, DEFAULT_REACTOTRON_CONFIG } from './reactotron-config' +import { mst } from 'reactotron-mst' +import { clear } from '../../utils/storage' +import { RootNavigation } from '../../navigators' +import { Platform } from 'react-native' + +// Teach TypeScript about the bad things we want to do. +declare global { + interface Console { + /** + * Hey, it's Reactotron if we're in dev, and no-ops if we're in prod. + */ + tron: typeof Tron + } +} + +/** Do Nothing. */ +const noop = () => undefined + +// in dev, we attach Reactotron, in prod we attach a interface-compatible mock. +if (__DEV__) { + console.tron = Tron // attach reactotron to `console.tron` +} else { + // attach a mock so if things sneaky by our __DEV__ guards, we won't crash. + console.tron = { + benchmark: noop, + clear: noop, + close: noop, + configure: noop, + connect: noop, + display: noop, + error: noop, + image: noop, + log: noop, + logImportant: noop, + onCustomCommand: noop, + overlay: noop, + reportError: noop, + send: noop, + startTimer: noop, + storybookSwitcher: noop, + use: noop, + useReactNative: noop, + warn: noop, + } +} + +/** + * You'll probably never use the service like this since we hang the Reactotron + * instance off of `console.tron`. This is only to be consistent with the other + * services. + */ +export class Reactotron { + config: ReactotronConfig + + rootStore: any + + /** + * Create the Reactotron service. + * + * @param config the configuration + */ + constructor(config: ReactotronConfig = DEFAULT_REACTOTRON_CONFIG) { + // merge the passed in config with some defaults + this.config = { + host: 'localhost', + useAsyncStorage: true, + ...config, + state: { + initial: false, + snapshots: false, + ...(config && config.state), + }, + } + } + + /** + * Hook into the root store for doing awesome state-related things. + * + * @param rootStore The root store + */ + setRootStore(rootStore: any, initialData: any) { + if (__DEV__) { + rootStore = rootStore as RootStore // typescript hack + this.rootStore = rootStore + + const { initial, snapshots } = this.config.state + const name = 'ROOT STORE' + + // logging features + if (initial) { + console.tron.display({ + name, + value: initialData, + preview: 'Initial State', + }) + } + // log state changes? + if (snapshots) { + onSnapshot(rootStore, (snapshot) => { + console.tron.display({ name, value: snapshot, preview: 'New State' }) + }) + } + + console.tron.trackMstNode(rootStore) + } + } + + /** + * Configure reactotron based on the the config settings passed in, then connect if we need to. + */ + async setup() { + // only run this in dev... metro bundler will ignore this block: 🎉 + if (__DEV__) { + // configure reactotron + Tron.configure({ + name: this.config.name || require('../../../package.json').name, + host: this.config.host, + }) + + // hookup middleware + if (Platform.OS !== 'web') { + if (this.config.useAsyncStorage) { + Tron.setAsyncStorageHandler(AsyncStorage) + } + Tron.useReactNative({ + asyncStorage: this.config.useAsyncStorage ? undefined : false, + }) + } + + // ignore some chatty `mobx-state-tree` actions + const RX = /postProcessSnapshot|@APPLY_SNAPSHOT/ + + // hookup mobx-state-tree middleware + Tron.use( + mst({ + filter: (event) => RX.test(event.name) === false, + }), + ) + + // connect to the app + Tron.connect() + + // Register Custom Commands + Tron.onCustomCommand({ + title: 'Reset Root Store', + description: 'Resets the MST store', + command: 'resetStore', + handler: () => { + console.tron.log('resetting store') + clear() + }, + }) + + Tron.onCustomCommand({ + title: 'Reset Navigation State', + description: 'Resets the navigation state', + command: 'resetNavigation', + handler: () => { + console.tron.log('resetting navigation state') + RootNavigation.resetRoot({ routes: [] }) + }, + }) + + Tron.onCustomCommand({ + title: 'Go Back', + description: 'Goes back', + command: 'goBack', + handler: () => { + console.tron.log('Going back') + RootNavigation.goBack() + }, + }) + + // clear if we should + if (this.config.clearOnLoad) { + Tron.clear() + } + } + } +} diff --git a/app_expo/services/reactotron/tron.ts b/app_expo/services/reactotron/tron.ts new file mode 100644 index 0000000..6086e48 --- /dev/null +++ b/app_expo/services/reactotron/tron.ts @@ -0,0 +1,2 @@ +import Reactotron from 'reactotron-react-native' +export const Tron = Reactotron diff --git a/app_expo/services/reactotron/tron.web.ts b/app_expo/services/reactotron/tron.web.ts new file mode 100644 index 0000000..a525c06 --- /dev/null +++ b/app_expo/services/reactotron/tron.web.ts @@ -0,0 +1,2 @@ +import Reactotron from 'reactotron-react-js' +export const Tron = Reactotron -- cgit v1.2.3