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