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