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