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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
|
/**
* Created by joel on 20/2/2018.
*
* This `network.ts` is the Media API arm of the netwok fetch requests.
* It is built on `Network` object and provides standard functionality specific for MAPI.
*
* @see `src/network.ts` for fetching from non-Media API endpoints
*/
import { isNothing, isSome } from "@jet/environment/types/optional";
import * as serverData from "../json-parsing/server-data";
import { MetricsIdentifierType } from "../metrics/metrics-identifiers-cache";
import { ResponseMetadata } from "../network/network";
import * as urls from "../network/urls";
import { extractMetaAssociationFromData } from "./data-fetching";
import * as urlBuilder from "./url-builder";
const secondaryUserIdHeaderName = "X-Apple-AppStore-UserId-Secondary";
/**
* Implements the MAPI fetch, building URL from MAPI Request and opaquely managing initial token request and refreshes.
*
* @param {Request} request MAPI Request to fetch with.
* @param {FetchOptions} options? FetchOptions for the MAPI request.
* @returns {Promise<Type>} Promise resolving to some type for given MAPI request.
*/
export async function fetchData(objectGraph, request, options) {
var _a;
const startTime = Date.now();
const token = await objectGraph.mediaToken.refreshToken();
const fetchTimingMetricsBuilder = objectGraph.fetchTimingMetricsBuilder;
const requestOptions = options !== null && options !== void 0 ? options : {};
const personalizationIdentifier = (_a = objectGraph.personalizationMetricsIdentifiersCache) === null || _a === void 0 ? void 0 : _a.getMetricsIdForType(MetricsIdentifierType.user);
if (isSome(personalizationIdentifier)) {
if (isSome(requestOptions === null || requestOptions === void 0 ? void 0 : requestOptions.headers)) {
requestOptions.headers[secondaryUserIdHeaderName] = personalizationIdentifier;
}
else {
requestOptions.headers = {
[secondaryUserIdHeaderName]: personalizationIdentifier,
};
}
}
const response = await fetchWithToken(objectGraph, request, token, requestOptions, false, fetchTimingMetricsBuilder);
const endTime = Date.now();
if (request.canonicalUrl) {
response[ResponseMetadata.requestedUrl] = request.canonicalUrl;
}
const roundTripTimeIncludingWaiting = endTime - startTime;
if (roundTripTimeIncludingWaiting > 500) {
const longFetchUrl = urlBuilder.buildURLFromRequest(objectGraph, request).toString();
objectGraph.console.warn("Fetch took too long (" + roundTripTimeIncludingWaiting.toString() + "ms) " + longFetchUrl);
}
return response;
}
export function redirectParametersInUrl(objectGraph, url) {
const redirectURLParams = objectGraph.bag.redirectUrlWhitelistedQueryParams;
return redirectURLParams.filter((param) => { var _a; return serverData.isDefinedNonNull((_a = url.query) === null || _a === void 0 ? void 0 : _a[param]); });
}
/**
* Given a built URL, token, and options, calls into native networking APIs to fetch content.
*
* @param {string} url URL to fetch data from.
* @param {string} request The original request used to build this url.
* @param {string} token MAPI token key.
* @param {FetchOptions} options Fetch options for MAPI requests.
* @param {boolean} isRetry flag indicating whether this is a fetch retry following a 401 request, and media token was refreshed.
* @returns {Promise<Type>} Promise resolving to some type for given MAPI request.
*/
async function fetchWithToken(objectGraph, request, token, options = {}, isRetry = false, fetchTimingMetricsBuilder) {
var _a, _b;
const originalUrl = urlBuilder.buildURLFromRequest(objectGraph, request).toString();
// Removes all affiliate/redirect params for caching (https://connectme.apple.com/docs/DOC-577671)
const filteredURL = new urls.URL(originalUrl);
const redirectParameters = redirectParametersInUrl(objectGraph, filteredURL);
for (const param of redirectParameters) {
filteredURL.removeParam(param);
}
const filteredUrlString = filteredURL.toString();
let headers = options.headers;
if (!headers) {
headers = {};
}
headers["Authorization"] = "Bearer " + token;
const response = await objectGraph.network.fetch({
url: filteredUrlString,
headers: headers,
method: options.method,
body: options.requestBodyString,
timeout: options.timeout,
});
try {
if (response.status === 401 || response.status === 403) {
if (isRetry) {
throw Error("We refreshed the token but we still get 401 from the API");
}
objectGraph.mediaToken.resetToken();
return await objectGraph.mediaToken.refreshToken().then(async (newToken) => {
// Explicitly re-fetch with the original request so logging and metrics are correct
return await fetchWithToken(objectGraph, request, newToken, options, true, fetchTimingMetricsBuilder);
});
}
else if (response.status === 404) {
// item is not available in this storefront or perhaps not at all
throw noContentError();
}
else if (!response.ok) {
const error = new NetworkError(`Bad Status code ${response.status} for ${filteredUrlString}, original ${originalUrl}`);
error.statusCode = response.status;
throw error;
}
const parser = (resp) => {
var _a;
const parseStartTime = Date.now();
let result;
if (serverData.isNull(resp.body) || resp.body === "") {
if (resp.status === 204) {
// 204 indicates a success, but the response will typically be empty
// Create a fake result so that we don't throw an error when JSON parsing
const emptyData = {};
result = emptyData;
}
else {
throw noContentError();
}
}
else {
try {
result = JSON.parse(resp.body);
}
catch (e) {
let errorMessage = e.message;
if (["debug", "internal"].includes(objectGraph.client.buildType)) {
errorMessage = `${e.message}, body: ${resp.body}`;
}
throw new JSONParseError(errorMessage);
}
}
const parseEndTime = Date.now();
if (result) {
result[ResponseMetadata.pageInformation] = serverData.asJSONData(getPageInformationFromResponse(objectGraph, resp));
if (resp.metrics.length > 0) {
const metrics = {
...resp.metrics[0],
parseStartTime: parseStartTime,
parseEndTime: parseEndTime,
};
result[ResponseMetadata.timingValues] = metrics;
}
else {
const fallbackMetrics = {
pageURL: resp.url,
parseStartTime,
parseEndTime,
};
result[ResponseMetadata.timingValues] = fallbackMetrics;
}
result[ResponseMetadata.contentMaxAge] = getContentTimeToLiveFromResponse(objectGraph, resp);
// If we have an empty data object, throw a 204 (No Content).
if (Array.isArray(result.data) &&
serverData.isArrayDefinedNonNullAndEmpty(result.data) &&
!serverData.asBooleanOrFalse(options.allowEmptyDataResponse)) {
throw noContentError();
}
if (Array.isArray(result.data) && request.originalOrdering.length > 1) {
result.data = buildHydratedDataListResponse(objectGraph, request.originalOrdering, (_a = result.data) !== null && _a !== void 0 ? _a : [], request.supplementaryMetadataAssociations);
}
result[ResponseMetadata.requestedUrl] = originalUrl;
}
return result;
};
if (isSome(fetchTimingMetricsBuilder)) {
return fetchTimingMetricsBuilder.measureParsing(response, parser);
}
else {
return parser(response);
}
}
catch (e) {
if (e instanceof NetworkError) {
throw e;
}
const correlationKey = (_a = response.headers["x-apple-jingle-correlation-key"]) !== null && _a !== void 0 ? _a : (_b = response.metrics[0]) === null || _b === void 0 ? void 0 : _b.clientCorrelationKey;
throw new Error(`Error Fetching - filtered: ${filteredUrlString}, original: ${originalUrl}, correlationKey: ${correlationKey !== null && correlationKey !== void 0 ? correlationKey : "N/A"}, ${e.name}, ${e.message}`);
}
}
export class NetworkError extends Error {
}
class JSONParseError extends Error {
}
export function noContentError() {
const error = new NetworkError(`No content`);
error.statusCode = 204;
return error;
}
export function notFoundError() {
const error = new NetworkError("Not found");
error.statusCode = 404;
return error;
}
const serverInstanceHeader = "x-apple-application-instance";
const environmentDataCenterHeader = "x-apple-application-site";
function getPageInformationFromResponse(objectGraph, response) {
const storeFrontHeader = objectGraph.client.storefrontIdentifier;
let storeFront = null;
if ((storeFrontHeader === null || storeFrontHeader === void 0 ? void 0 : storeFrontHeader.length) > 0) {
const storeFrontHeaderComponents = storeFrontHeader.split("-");
if (serverData.isDefinedNonNullNonEmpty(storeFrontHeaderComponents)) {
storeFront = storeFrontHeaderComponents[0];
}
}
return {
serverInstance: response.headers[serverInstanceHeader],
storeFrontHeader: storeFrontHeader,
language: objectGraph.bag.language,
storeFront: storeFront,
environmentDataCenter: response.headers[environmentDataCenterHeader],
};
}
function getContentTimeToLiveFromResponse(objectGraph, response) {
const cacheControlHeaderKey = Object.keys(response.headers).find((key) => key.toLowerCase() === "cache-control");
if (serverData.isNull(cacheControlHeaderKey) || cacheControlHeaderKey === "") {
return null;
}
const headerValue = response.headers[cacheControlHeaderKey];
if (serverData.isNullOrEmpty(headerValue)) {
return null;
}
const matches = headerValue.match(/max-age=(\d+)/);
if (serverData.isNull(matches) || matches.length < 2) {
return null;
}
return serverData.asNumber(matches[1]);
}
/**
* Builds the hydrated list of shelf items, based on the data in the response, and the original unhydated items.
*/
function buildHydratedDataListResponse(objectGraph, unhydratedData, hydratedDataCollection, associations = []) {
// Create a map of all the hydrated data items from the response, using the resource type and ID as the key
const hydratedDataMap = {};
for (const dataItem of hydratedDataCollection) {
const key = dataMapKey(objectGraph, dataItem.type, dataItem.id);
hydratedDataMap[key] = dataItem;
}
// Next iterate through the unhyrated items in their original order, and swap in the hydrated item.
const hydratedItems = [];
for (const unhydratedItem of unhydratedData) {
const key = dataMapKey(objectGraph, unhydratedItem.type, unhydratedItem.id);
const hydratedItem = hydratedDataMap[key];
if (isSome(hydratedItem)) {
/// Start with a base of what was there already, this is to ensure we don't lose any existing meta data
/// if some of the requested associations are not present in the response.
if (serverData.isDefinedNonNullNonEmpty(associations)) {
hydratedItem.meta = {
...unhydratedItem.meta,
};
for (const association of associations) {
hydrateMetaAssociationIfNecessary(objectGraph, association, hydratedItem, unhydratedItem, hydratedDataMap);
}
}
hydratedItems.push(hydratedItem);
}
}
return hydratedItems;
}
/**
* Creates a key to use in a mapping of data items.
* @param objectGraph Current object graph
* @param data The data item
* @returns A string composed from the data item ID and resource type
*/
function dataMapKey(objectGraph, resourceType, id) {
return `${resourceType}_${id}`;
}
/**
* @param objectGraph The current object graph
* @param association The association we'd like to hydrate in the meta object
* @param unhydratedItem The original MAPI data item to look for editorial card, and fill in with a fetched card if there is one
* @param hydratedDataMap The data map of all the fetched items keyed by id / type
*/
function hydrateMetaAssociationIfNecessary(objectGraph, association, hydratedItem, unhydratedItem, hydratedDataMap) {
var _a;
if (isNothing(hydratedItem.meta)) {
hydratedItem.meta = {
associations: {},
};
}
else if (isNothing(hydratedItem.meta.associations)) {
hydratedItem.meta.associations = {};
}
const unhydratedAssociationsData = extractMetaAssociationFromData(association, unhydratedItem);
if (serverData.isDefinedNonNullNonEmpty(unhydratedAssociationsData)) {
const hydratedAssociationData = [];
for (const unhydratedAssociation of unhydratedAssociationsData) {
const associationDataKey = dataMapKey(objectGraph, unhydratedAssociation.type, unhydratedAssociation.id);
const associationData = hydratedDataMap[associationDataKey];
if (isSome(associationData)) {
hydratedAssociationData.push(associationData);
}
}
const hydratedAssociations = (_a = serverData.asDictionary(hydratedItem.meta.associations)) !== null && _a !== void 0 ? _a : {};
hydratedAssociations[association] = {
data: hydratedAssociationData,
};
}
}
//# sourceMappingURL=network.js.map
|