src/amo/api/index.js (308 lines of code) (raw):

/* @flow */ /* global window */ import url from 'url'; import FormData from '@willdurand/isomorphic-formdata'; import { oneLine } from 'common-tags'; import config from 'config'; import languages from 'amo/languages'; import { initialApiState } from 'amo/reducers/api'; import log from 'amo/logger'; import { addVersionCompatibilityToFilters, convertFiltersToQueryParams, fixFiltersForClientApp, } from 'amo/searchUtils'; import { addQueryParams } from 'amo/utils/url'; import type { ApiState } from 'amo/reducers/api'; import type { ExternalSuggestion } from 'amo/reducers/autocomplete'; import type { ExternalAddonType } from 'amo/types/addons'; import type { LocalizedString, PaginatedApiResponse } from 'amo/types/api'; import type { ErrorHandlerType } from 'amo/types/errorHandler'; import type { ReactRouterLocationType } from 'amo/types/router'; const API_BASE = `${config.get('apiHost')}${config.get('apiPath')}`; export const DEFAULT_API_PAGE_SIZE = 25; export const REGION_CODE_HEADER = 'X-Country-Code'; export function makeQueryString(query: { [key: string]: string | null, }): string { const resolvedQuery = { ...query }; Object.keys(resolvedQuery).forEach((key) => { const value = resolvedQuery[key]; if (value === undefined || value === null || value === '') { // Make sure we don't turn this into ?key= (empty string) because // sending an empty string to the API sometimes triggers bugs. delete resolvedQuery[key]; } }); return url.format({ query: resolvedQuery }); } type CreateApiErrorParams = {| apiURL?: string, response: Response, jsonResponse?: Object, |}; export function createApiError({ apiURL, response, jsonResponse, }: CreateApiErrorParams): Error { let urlId = '[unknown URL]'; if (apiURL) { // Strip the host since we already know that. urlId = apiURL.replace(config.get('apiHost'), ''); // Strip query string params since lang will vary quite a lot. urlId = urlId.split('?')[0]; } const apiError = new Error( `Error calling: ${urlId} (status: ${response.status})`, ); // $FlowFixMe: turn Error into a custom ApiError class. apiError.response = { apiURL, status: response.status, data: jsonResponse, }; return apiError; } type CallApiParams = {| apiState?: ApiState, auth?: boolean, body?: Object | typeof FormData, credentials?: boolean, endpoint: string, errorHandler?: ErrorHandlerType, method?: 'GET' | 'POST' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'PUT' | 'PATCH', params?: Object, _config?: typeof config, version?: string, _log?: typeof log, |}; export function callApi({ endpoint, params = {}, auth = false, apiState = initialApiState, method = 'GET', body, credentials, errorHandler, _config = config, version = _config.get('apiVersion'), _log = log, }: CallApiParams): Promise<Object> { if (!endpoint) { return Promise.reject( new Error(`endpoint URL cannot be falsy: "${endpoint}"`), ); } if (errorHandler) { errorHandler.clear(); } const apiPath = `${config.get('apiPath')}${version}`; const parsedUrl = url.parse(endpoint, true); let adjustedEndpoint = parsedUrl.pathname || ''; if (!parsedUrl.host) { // If it's a relative URL, add the API prefix. const slash = !adjustedEndpoint.startsWith('/') ? '/' : ''; adjustedEndpoint = `${apiPath}${slash}${adjustedEndpoint}`; } else if (!adjustedEndpoint.startsWith(apiPath)) { // If it's an absolute URL, it must have the correct prefix. return Promise.reject( new Error(`Absolute URL "${endpoint}" has an unexpected prefix.`), ); } // Preserve the original query string if there is one. // This might happen when we parse `next` URLs returned by the API. const queryString = makeQueryString({ ...parsedUrl.query, ...params, lang: apiState.lang, }); const options = { headers: {}, // Always make sure the method is upper case so that the browser won't // complain about CORS problems. method: method.toUpperCase(), credentials: undefined, body: undefined, }; if (credentials) { options.credentials = 'include'; } if (body) { if (body instanceof FormData) { options.body = body; // Let the browser sets this header, including the boundary value. // $FlowIgnore delete options.headers['Content-type']; } else { options.body = JSON.stringify(body); options.headers['Content-type'] = 'application/json'; } } if (auth) { if (apiState.token) { options.headers.authorization = `Session ${apiState.token}`; } } if (apiState.regionCode) { options.headers[REGION_CODE_HEADER] = apiState.regionCode; } adjustedEndpoint = adjustedEndpoint.endsWith('/') ? adjustedEndpoint : `${adjustedEndpoint}/`; const apiURL = `${config.get('apiHost')}${adjustedEndpoint}${queryString}`; // Flow expects headers['Content-type'] to be a string, but we sometimes // delete it at line 148, above. // $FlowIgnore return fetch(apiURL, options) .then((response) => { // There isn't always a 'Content-Type' in headers, e.g., with a DELETE // method or 5xx responses. let contentType = response.headers.get('Content-Type'); contentType = contentType && contentType.toLowerCase(); // This is a bit paranoid, but we ensure the API returns a JSON response // (see https://github.com/mozilla/addons-frontend/issues/1701). // If not we'll store the text response in JSON and log an error. // If the JSON parsing fails; we log the error and return an "unknown // error". if (contentType === 'application/json') { return response .json() .then((jsonResponse) => ({ response, jsonResponse })); } return response.text().then((text) => { // eslint-disable-next-line amo/only-log-strings _log.warn( oneLine`Response from API was not JSON (was Content-Type: ${contentType}) %o`, { body: text ? text.substring(0, 100) : '[empty]', status: response.status || '[unknown]', url: response.url || '[unknown]', }, ); // jsonResponse should be an empty object in this case. Otherwise, its // keys could be treated as generic API errors. return { jsonResponse: {}, response }; }); }) .then( ({ response, jsonResponse }) => { if (response.ok) { return jsonResponse; } // If response is not ok we'll throw an error. const apiError = createApiError({ apiURL, response, jsonResponse }); if (errorHandler) { errorHandler.handle(apiError); } throw apiError; }, (fetchError) => { // This actually handles the case when the call to fetch() is // rejected, say, for a network connection error, etc. if (errorHandler) { errorHandler.handle(fetchError); } throw fetchError; }, ); } export type FetchAddonParams = {| api: ApiState, showGroupedRatings?: boolean, slug: string, |}; export function fetchAddon({ api, showGroupedRatings = false, slug, }: FetchAddonParams): Promise<ExternalAddonType> { const { clientApp, userAgentInfo } = api; const appVersion = userAgentInfo.browser.version; if (!appVersion) { log.debug( `Failed to parse appversion for client app ${clientApp || '[empty]'}`, ); } return callApi({ endpoint: addQueryParams(`addons/addon/${slug}`, { app: clientApp, appversion: appVersion || '', show_grouped_ratings: String(showGroupedRatings), }), auth: true, apiState: api, }); } export function startLoginUrl({ _config = config, _window = typeof window !== 'undefined' ? window : null, location, }: {| _config?: typeof config, _window?: typeof window | null, location: ReactRouterLocationType, |}): string { const fxaConfig = _config.get('fxaConfig'); const params = { config: fxaConfig, to: fxaConfig === 'local' && _window ? _window.location.href : url.format({ ...location }), }; const query = makeQueryString(params); return `${API_BASE}${_config.get( 'apiVersion', )}/accounts/login/start/${query}`; } export function logOutFromServer({ api, }: {| api: ApiState, |}): Promise<Object> { return callApi({ auth: true, credentials: true, endpoint: 'accounts/session', method: 'DELETE', apiState: api, }); } export type AutocompleteParams = {| _fixFiltersForClientApp?: typeof fixFiltersForClientApp, api: ApiState, filters: {| query: string, addonType?: string, |}, |}; export function autocomplete({ _fixFiltersForClientApp = fixFiltersForClientApp, api, filters, }: AutocompleteParams): Promise<Array<ExternalSuggestion>> { const filtersWithAppVersion = addVersionCompatibilityToFilters({ filters: _fixFiltersForClientApp({ api, filters }), userAgentInfo: api.userAgentInfo, }); return callApi({ endpoint: 'addons/autocomplete', params: { app: api.clientApp, ...convertFiltersToQueryParams(filtersWithAppVersion), }, apiState: api, }); } type GetNextResponseType = ( nextURL?: string, ) => Promise<PaginatedApiResponse<any>>; type AllPagesOptions = { pageLimit: number }; export const allPages = async ( getNextResponse: GetNextResponseType, { pageLimit = 100 }: AllPagesOptions = {}, ): Promise<PaginatedApiResponse<any>> => { let results = []; let nextURL; let count = 0; let pageSize = 0; for (let page = 1; page <= pageLimit; page++) { const response = await getNextResponse(nextURL); if (!count) { // Every response page returns a count for all results. count = response.count; } if (!pageSize) { pageSize = parseInt(response.page_size, 10); } results = results.concat(response.results); if (response.next) { nextURL = response.next; log.debug(`Fetching next page "${nextURL}"`); } else { return { count, page_size: String(pageSize), results }; } } // If we get this far the callback may not be advancing pages correctly. throw new Error(`Fetched too many pages (the limit is ${pageLimit})`); }; export const validateLocalizedString = (localizedString: LocalizedString) => { if (typeof localizedString !== 'object') { throw new Error(`Expected an object type, got "${typeof localizedString}"`); } Object.keys(localizedString).forEach((localeKey) => { if (typeof languages[localeKey] === 'undefined') { throw new Error(`Unknown locale: "${localeKey}"`); } }); };