forked from Expensify/App
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathHttpUtils.ts
223 lines (200 loc) · 9.98 KB
/
HttpUtils.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import alert from '@components/Alert';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {RequestType} from '@src/types/onyx/Request';
import type Response from '@src/types/onyx/Response';
import {setTimeSkew} from './actions/Network';
import {alertUser} from './actions/UpdateRequired';
import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from './API/types';
import {getCommandURL} from './ApiUtils';
import HttpsError from './Errors/HttpsError';
import getPlatform from './getPlatform';
const platform = getPlatform();
const isNativePlatform = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS;
let shouldFailAllRequests = false;
let shouldForceOffline = false;
const ABORT_COMMANDS = {
All: 'All',
[READ_COMMANDS.SEARCH_FOR_REPORTS]: READ_COMMANDS.SEARCH_FOR_REPORTS,
} as const;
type AbortCommand = keyof typeof ABORT_COMMANDS;
Onyx.connect({
key: ONYXKEYS.NETWORK,
callback: (network) => {
if (!network) {
return;
}
shouldFailAllRequests = !!network.shouldFailAllRequests;
shouldForceOffline = !!network.shouldForceOffline;
},
});
// We use the AbortController API to terminate pending request in `cancelPendingRequests`
const abortControllerMap = new Map<AbortCommand, AbortController>();
abortControllerMap.set(ABORT_COMMANDS.All, new AbortController());
abortControllerMap.set(ABORT_COMMANDS.SearchForReports, new AbortController());
/**
* The API commands that require the skew calculation
*/
const addSkewList: string[] = [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_REPORT, SIDE_EFFECT_REQUEST_COMMANDS.RECONNECT_APP, WRITE_COMMANDS.OPEN_APP];
/**
* Regex to get API command from the command
*/
const APICommandRegex = /\/api\/([^&?]+)\??.*/;
/**
* Send an HTTP request, and attempt to resolve the json response.
* If there is a network error, we'll set the application offline.
*/
function processHTTPRequest(url: string, method: RequestType = 'get', body: FormData | null = null, abortSignal: AbortSignal | undefined = undefined): Promise<Response> {
const startTime = new Date().valueOf();
return fetch(url, {
// We hook requests to the same Controller signal, so we can cancel them all at once
signal: abortSignal,
method,
body,
// On Web fetch already defaults to 'omit' for credentials, but it seems that this is not the case for the ReactNative implementation
// so to avoid sending cookies with the request we set it to 'omit' explicitly
// this avoids us sending specially the expensifyWeb cookie, which makes a CSRF token required
// more on that here: https://stackoverflowteams.com/c/expensify/questions/93
credentials: 'omit',
})
.then((response) => {
// We are calculating the skew to minimize the delay when posting the messages
const match = url.match(APICommandRegex)?.[1];
if (match && addSkewList.includes(match) && response.headers) {
const dateHeaderValue = response.headers.get('Date');
const serverTime = dateHeaderValue ? new Date(dateHeaderValue).valueOf() : new Date().valueOf();
const endTime = new Date().valueOf();
const latency = (endTime - startTime) / 2;
const skew = serverTime - startTime + latency;
setTimeSkew(dateHeaderValue ? skew : 0);
}
return response;
})
.then((response) => {
// Test mode where all requests will succeed in the server, but fail to return a response
if (shouldFailAllRequests || shouldForceOffline) {
throw new HttpsError({
message: CONST.ERROR.FAILED_TO_FETCH,
});
}
if (!response.ok) {
// Expensify site is down or there was an internal server error, or something temporary like a Bad Gateway, or unknown error occurred
const serviceInterruptedStatuses: Array<ValueOf<typeof CONST.HTTP_STATUS>> = [
CONST.HTTP_STATUS.INTERNAL_SERVER_ERROR,
CONST.HTTP_STATUS.BAD_GATEWAY,
CONST.HTTP_STATUS.GATEWAY_TIMEOUT,
CONST.HTTP_STATUS.UNKNOWN_ERROR,
];
if (serviceInterruptedStatuses.indexOf(response.status as ValueOf<typeof CONST.HTTP_STATUS>) > -1) {
throw new HttpsError({
message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED,
status: response.status.toString(),
title: 'Issue connecting to Expensify site',
});
}
if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) {
throw new HttpsError({
message: CONST.ERROR.THROTTLED,
status: response.status.toString(),
title: 'API request throttled',
});
}
throw new HttpsError({
message: response.statusText,
status: response.status.toString(),
});
}
return response.json() as Promise<Response>;
})
.then((response) => {
// Some retried requests will result in a "Unique Constraints Violation" error from the server, which just means the record already exists
if (response.jsonCode === CONST.JSON_CODE.BAD_REQUEST && response.message === CONST.ERROR_TITLE.DUPLICATE_RECORD) {
throw new HttpsError({
message: CONST.ERROR.DUPLICATE_RECORD,
status: CONST.JSON_CODE.BAD_REQUEST.toString(),
title: CONST.ERROR_TITLE.DUPLICATE_RECORD,
});
}
// Auth is down or timed out while making a request
if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR && response.title === CONST.ERROR_TITLE.SOCKET && response.type === CONST.ERROR_TYPE.SOCKET) {
throw new HttpsError({
message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED,
status: CONST.JSON_CODE.EXP_ERROR.toString(),
title: CONST.ERROR_TITLE.SOCKET,
});
}
if (response.data && (response.data?.authWriteCommands?.length ?? 0)) {
const {phpCommandName, authWriteCommands} = response.data;
const message = `The API command ${phpCommandName} is doing too many Auth writes. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join(
', ',
)}. If you modified this command, you MUST refactor it to remove the extra Auth writes. Otherwise, update the allowed write count in Web-Expensify APIWriteCommands.`;
alert('Too many auth writes', message);
}
if (response.jsonCode === CONST.JSON_CODE.UPDATE_REQUIRED) {
// Trigger a modal and disable the app as the user needs to upgrade to the latest minimum version to continue
alertUser();
}
return response as Promise<Response>;
});
}
/**
* Makes XHR request
* @param command the name of the API command
* @param data parameters for the API command
* @param type HTTP request type (get/post)
* @param shouldUseSecure should we use the secure server
*/
function xhr(command: string, data: Record<string, unknown>, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise<Response> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
const value = data[key];
if (value === undefined) {
return;
}
validateFormDataParameter(command, key, value);
formData.append(key, value as string | Blob);
});
const url = getCommandURL({shouldUseSecure, command});
const abortSignalController = data.canCancel ? abortControllerMap.get(command as AbortCommand) ?? abortControllerMap.get(ABORT_COMMANDS.All) : undefined;
return processHTTPRequest(url, type, formData, abortSignalController?.signal);
}
/**
* Ensures no value of type `object` other than null, Blob, its subclasses, or {uri: string} (native platforms only) is passed to XMLHttpRequest.
* Otherwise, it will be incorrectly serialized as `[object Object]` and cause an error on Android.
* See https://github.com/Expensify/App/issues/45086
*/
function validateFormDataParameter(command: string, key: string, value: unknown) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const isValid = (value: unknown, isTopLevel: boolean): boolean => {
if (value === null || typeof value !== 'object') {
return true;
}
if (Array.isArray(value)) {
return value.every((element) => isValid(element, false));
}
if (isTopLevel) {
// Native platforms only require the value to include the `uri` property.
// Optionally, it can also have a `name` and `type` props.
// On other platforms, the value must be an instance of `Blob`.
return isNativePlatform ? 'uri' in value && !!value.uri : value instanceof Blob;
}
return false;
};
if (!isValid(value, true)) {
// eslint-disable-next-line no-console
console.warn(`An unsupported value was passed to command '${command}' (parameter: '${key}'). Only Blob and primitive types are allowed.`);
}
}
function cancelPendingRequests(command: AbortCommand = ABORT_COMMANDS.All) {
const controller = abortControllerMap.get(command);
controller?.abort();
// We create a new instance because once `abort()` is called any future requests using the same controller would
// automatically get rejected: https://dom.spec.whatwg.org/#abortcontroller-api-integration
abortControllerMap.set(command, new AbortController());
}
export default {
xhr,
cancelPendingRequests,
};