summaryrefslogtreecommitdiff
path: root/app/services
diff options
context:
space:
mode:
Diffstat (limited to 'app/services')
-rw-r--r--app/services/api/api-config.ts27
-rw-r--r--app/services/api/api-problem.test.ts72
-rw-r--r--app/services/api/api-problem.ts74
-rw-r--r--app/services/api/api.ts102
-rw-r--r--app/services/api/api.types.ts13
-rw-r--r--app/services/api/character-api.ts37
-rw-r--r--app/services/api/index.ts2
-rw-r--r--app/services/reactotron/index.ts1
-rw-r--r--app/services/reactotron/reactotron-config.ts30
-rw-r--r--app/services/reactotron/reactotron.ts181
-rw-r--r--app/services/reactotron/tron.ts2
-rw-r--r--app/services/reactotron/tron.web.ts2
12 files changed, 543 insertions, 0 deletions
diff --git a/app/services/api/api-config.ts b/app/services/api/api-config.ts
new file mode 100644
index 0000000..3061563
--- /dev/null
+++ b/app/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/services/api/api-problem.test.ts b/app/services/api/api-problem.test.ts
new file mode 100644
index 0000000..ccd35c9
--- /dev/null
+++ b/app/services/api/api-problem.test.ts
@@ -0,0 +1,72 @@
+import { getGeneralApiProblem } from "./api-problem"
+import { ApiErrorResponse } from "apisauce"
+
+test("handles connection errors", () => {
+ expect(getGeneralApiProblem({ problem: "CONNECTION_ERROR" } as ApiErrorResponse<null>)).toEqual({
+ kind: "cannot-connect",
+ temporary: true,
+ })
+})
+
+test("handles network errors", () => {
+ expect(getGeneralApiProblem({ problem: "NETWORK_ERROR" } as ApiErrorResponse<null>)).toEqual({
+ kind: "cannot-connect",
+ temporary: true,
+ })
+})
+
+test("handles timeouts", () => {
+ expect(getGeneralApiProblem({ problem: "TIMEOUT_ERROR" } as ApiErrorResponse<null>)).toEqual({
+ kind: "timeout",
+ temporary: true,
+ })
+})
+
+test("handles server errors", () => {
+ expect(getGeneralApiProblem({ problem: "SERVER_ERROR" } as ApiErrorResponse<null>)).toEqual({
+ kind: "server",
+ })
+})
+
+test("handles unknown errors", () => {
+ expect(getGeneralApiProblem({ problem: "UNKNOWN_ERROR" } as ApiErrorResponse<null>)).toEqual({
+ kind: "unknown",
+ temporary: true,
+ })
+})
+
+test("handles unauthorized errors", () => {
+ expect(
+ getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 401 } as ApiErrorResponse<null>),
+ ).toEqual({
+ kind: "unauthorized",
+ })
+})
+
+test("handles forbidden errors", () => {
+ expect(
+ getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 403 } as ApiErrorResponse<null>),
+ ).toEqual({
+ kind: "forbidden",
+ })
+})
+
+test("handles not-found errors", () => {
+ expect(
+ getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 404 } as ApiErrorResponse<null>),
+ ).toEqual({
+ kind: "not-found",
+ })
+})
+
+test("handles other client errors", () => {
+ expect(
+ getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 418 } as ApiErrorResponse<null>),
+ ).toEqual({
+ kind: "rejected",
+ })
+})
+
+test("handles cancellation errors", () => {
+ expect(getGeneralApiProblem({ problem: "CANCEL_ERROR" } as ApiErrorResponse<null>)).toBeNull()
+})
diff --git a/app/services/api/api-problem.ts b/app/services/api/api-problem.ts
new file mode 100644
index 0000000..9c2aa49
--- /dev/null
+++ b/app/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<any>): 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/services/api/api.ts b/app/services/api/api.ts
new file mode 100644
index 0000000..15f6815
--- /dev/null
+++ b/app/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<Types.GetUsersResult> {
+ // make the api call
+ const response: ApiResponse<any> = 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<Types.GetUserResult> {
+ // make the api call
+ const response: ApiResponse<any> = 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/services/api/api.types.ts b/app/services/api/api.types.ts
new file mode 100644
index 0000000..dde58c0
--- /dev/null
+++ b/app/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/services/api/character-api.ts b/app/services/api/character-api.ts
new file mode 100644
index 0000000..7755006
--- /dev/null
+++ b/app/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<GetCharactersResult> {
+ try {
+ // make the api call
+ const response: ApiResponse<any> = 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/services/api/index.ts b/app/services/api/index.ts
new file mode 100644
index 0000000..a12bb55
--- /dev/null
+++ b/app/services/api/index.ts
@@ -0,0 +1,2 @@
+export * from "./api"
+export * from "./api.types"
diff --git a/app/services/reactotron/index.ts b/app/services/reactotron/index.ts
new file mode 100644
index 0000000..60a8b40
--- /dev/null
+++ b/app/services/reactotron/index.ts
@@ -0,0 +1 @@
+export * from "./reactotron"
diff --git a/app/services/reactotron/reactotron-config.ts b/app/services/reactotron/reactotron-config.ts
new file mode 100644
index 0000000..dd87bd7
--- /dev/null
+++ b/app/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/services/reactotron/reactotron.ts b/app/services/reactotron/reactotron.ts
new file mode 100644
index 0000000..0ec12ad
--- /dev/null
+++ b/app/services/reactotron/reactotron.ts
@@ -0,0 +1,181 @@
+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/services/reactotron/tron.ts b/app/services/reactotron/tron.ts
new file mode 100644
index 0000000..f6c872d
--- /dev/null
+++ b/app/services/reactotron/tron.ts
@@ -0,0 +1,2 @@
+import Reactotron from "reactotron-react-native"
+export const Tron = Reactotron
diff --git a/app/services/reactotron/tron.web.ts b/app/services/reactotron/tron.web.ts
new file mode 100644
index 0000000..bcee53b
--- /dev/null
+++ b/app/services/reactotron/tron.web.ts
@@ -0,0 +1,2 @@
+import Reactotron from "reactotron-react-js"
+export const Tron = Reactotron