xref: /expo/packages/@expo/cli/src/api/rest/client.ts (revision dfd15ebd)
1import { getExpoHomeDirectory } from '@expo/config/build/getUserState';
2import { JSONValue } from '@expo/json-file';
3import fetchInstance from 'node-fetch';
4import path from 'path';
5
6import { env } from '../../utils/env';
7import { getExpoApiBaseUrl } from '../endpoint';
8import UserSettings from '../user/UserSettings';
9import { FileSystemCache } from './cache/FileSystemCache';
10import { wrapFetchWithCache } from './cache/wrapFetchWithCache';
11import { FetchLike } from './client.types';
12import { wrapFetchWithBaseUrl } from './wrapFetchWithBaseUrl';
13import { wrapFetchWithOffline } from './wrapFetchWithOffline';
14import { wrapFetchWithProgress } from './wrapFetchWithProgress';
15import { wrapFetchWithProxy } from './wrapFetchWithProxy';
16
17export class ApiV2Error extends Error {
18  readonly name = 'ApiV2Error';
19  readonly code: string;
20  readonly expoApiV2ErrorCode: string;
21  readonly expoApiV2ErrorDetails?: JSONValue;
22  readonly expoApiV2ErrorServerStack?: string;
23  readonly expoApiV2ErrorMetadata?: object;
24
25  constructor(response: {
26    message: string;
27    code: string;
28    stack?: string;
29    details?: JSONValue;
30    metadata?: object;
31  }) {
32    super(response.message);
33    this.code = response.code;
34    this.expoApiV2ErrorCode = response.code;
35    this.expoApiV2ErrorDetails = response.details;
36    this.expoApiV2ErrorServerStack = response.stack;
37    this.expoApiV2ErrorMetadata = response.metadata;
38  }
39}
40
41/**
42 * An Expo server error that didn't return the expected error JSON information.
43 * The only 'expected' place for this is in testing, all other cases are bugs with the server.
44 */
45export class UnexpectedServerError extends Error {
46  readonly name = 'UnexpectedServerError';
47}
48
49/**
50 * @returns a `fetch` function that will inject user authentication information and handle errors from the Expo API.
51 */
52export function wrapFetchWithCredentials(fetchFunction: FetchLike): FetchLike {
53  return async function fetchWithCredentials(url, options = {}) {
54    if (Array.isArray(options.headers)) {
55      throw new Error('request headers must be in object form');
56    }
57
58    const resolvedHeaders = options.headers ?? ({} as any);
59
60    const token = UserSettings.getAccessToken();
61    if (token) {
62      resolvedHeaders.authorization = `Bearer ${token}`;
63    } else {
64      const sessionSecret = UserSettings.getSession()?.sessionSecret;
65      if (sessionSecret) {
66        resolvedHeaders['expo-session'] = sessionSecret;
67      }
68    }
69
70    const results = await fetchFunction(url, {
71      ...options,
72      headers: resolvedHeaders,
73    });
74
75    if (results.status >= 400 && results.status < 500) {
76      const body = await results.text();
77      try {
78        const data = JSON.parse(body);
79        if (data?.errors?.length) {
80          throw new ApiV2Error(data.errors[0]);
81        }
82      } catch (error: any) {
83        // Server returned non-json response.
84        if (error.message.includes('in JSON at position')) {
85          throw new UnexpectedServerError(body);
86        }
87        throw error;
88      }
89    }
90    return results;
91  };
92}
93
94const fetchWithOffline = wrapFetchWithOffline(fetchInstance);
95
96const fetchWithBaseUrl = wrapFetchWithBaseUrl(fetchWithOffline, getExpoApiBaseUrl() + '/v2/');
97
98const fetchWithProxy = wrapFetchWithProxy(fetchWithBaseUrl);
99
100const fetchWithCredentials = wrapFetchWithProgress(wrapFetchWithCredentials(fetchWithProxy));
101
102/**
103 * Create an instance of the fully qualified fetch command (auto authentication and api) but with caching in the '~/.expo' directory.
104 * Caching is disabled automatically if the EXPO_NO_CACHE or EXPO_BETA environment variables are enabled.
105 */
106export function createCachedFetch({
107  fetch = fetchWithCredentials,
108  cacheDirectory,
109  ttl,
110  skipCache,
111}: {
112  fetch?: FetchLike;
113  cacheDirectory: string;
114  ttl?: number;
115  skipCache?: boolean;
116}): FetchLike {
117  // Disable all caching in EXPO_BETA.
118  if (skipCache || env.EXPO_BETA || env.EXPO_NO_CACHE) {
119    return fetch;
120  }
121
122  return wrapFetchWithCache(
123    fetch,
124    new FileSystemCache({
125      cacheDirectory: path.join(getExpoHomeDirectory(), cacheDirectory),
126      ttl,
127    })
128  );
129}
130
131/** Instance of fetch with automatic base URL pointing to the Expo API, user credential injection, and API error handling. Caching not included.  */
132export const fetchAsync = wrapFetchWithProgress(wrapFetchWithCredentials(fetchWithProxy));
133