diff options
author | Thomas F. K. Jorna <[email protected]> | 2021-07-14 15:10:31 +0200 |
---|---|---|
committer | Thomas F. K. Jorna <[email protected]> | 2021-07-14 15:10:31 +0200 |
commit | e5021187e96b78b53203bd95d08d6818aea47d17 (patch) | |
tree | 37ec45d00eb963db53cd4bb4f04a770414b351cc /app/services/api |
New Ignite 7.0.6 app
Diffstat (limited to 'app/services/api')
-rw-r--r-- | app/services/api/api-config.ts | 27 | ||||
-rw-r--r-- | app/services/api/api-problem.test.ts | 72 | ||||
-rw-r--r-- | app/services/api/api-problem.ts | 74 | ||||
-rw-r--r-- | app/services/api/api.ts | 102 | ||||
-rw-r--r-- | app/services/api/api.types.ts | 13 | ||||
-rw-r--r-- | app/services/api/character-api.ts | 37 | ||||
-rw-r--r-- | app/services/api/index.ts | 2 |
7 files changed, 327 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" |