UNPKG

@esri/arcgis-rest-request

Version:

Common methods and utilities for @esri/arcgis-rest-js packages.

465 lines 22.4 kB
/* Copyright (c) 2017-2018 Environmental Systems Research Institute, Inc. * Apache-2.0 */ import { __rest } from "tslib"; import { encodeFormData } from "./utils/encode-form-data.js"; import { encodeQueryString } from "./utils/encode-query-string.js"; import { requiresFormData } from "./utils/process-params.js"; import { ArcGISRequestError } from "./utils/ArcGISRequestError.js"; import { isNoCorsDomain, isNoCorsRequestRequired, registerNoCorsDomains, sendNoCorsRequest } from "./utils/sendNoCorsRequest.js"; import { warn } from "./utils/warn.js"; import { getFetch } from "@esri/arcgis-rest-fetch"; import { isSameOrigin } from "./utils/isSameOrigin.js"; export const NODEJS_DEFAULT_REFERER_HEADER = `@esri/arcgis-rest-js`; /** * Sets the default options that will be passed in **all requests across all `@esri/arcgis-rest-js` modules**. * * ```js * import { setDefaultRequestOptions } from "@esri/arcgis-rest-request"; * * setDefaultRequestOptions({ * authentication: ArcGISIdentityManager // all requests will use this session by default * }) * ``` * * You should **never** set a default `authentication` when you are in a server side environment where you may be handling requests for many different authenticated users. * * @param options The default options to pass with every request. Existing default will be overwritten. * @param hideWarnings Silence warnings about setting default `authentication` in shared environments. */ export function setDefaultRequestOptions(options, hideWarnings) { if (options.authentication && !hideWarnings) { warn("You should not set `authentication` as a default in a shared environment such as a web server which will process multiple users requests. You can call `setDefaultRequestOptions` with `true` as a second argument to disable this warning."); } globalThis.DEFAULT_ARCGIS_REQUEST_OPTIONS = options; } export function getDefaultRequestOptions() { return (globalThis.DEFAULT_ARCGIS_REQUEST_OPTIONS || { httpMethod: "POST", params: { f: "json" } }); } /** * This error is thrown when a request encounters an invalid token error. Requests that use {@linkcode ArcGISIdentityManager} or * {@linkcode ApplicationCredentialsManager} in the `authentication` option the authentication manager will automatically try to generate * a fresh token using either {@linkcode ArcGISIdentityManager.refreshCredentials} or * {@linkcode ApplicationCredentialsManager.refreshCredentials}. If the request with the new token fails you will receive an `ArcGISAuthError` * if refreshing the token fails you will receive an instance of {@linkcode ArcGISTokenRequestError}. * * ```js * request(someUrl, { * authentication: identityManager, * // some additional options... * }).catch(e => { * if(e.name === "ArcGISAuthError") { * console.log("Request with a new token failed you might want to have the user authorize again.") * } * * if(e.name === "ArcGISTokenRequestError") { * console.log("There was an error refreshing the token you might want to have the user authorize again.") * } * }) * ``` */ export class ArcGISAuthError extends ArcGISRequestError { /** * Create a new `ArcGISAuthError` object. * * @param message - The error message from the API * @param code - The error code from the API * @param response - The original response from the API that caused the error * @param url - The original url of the request * @param options - The original options of the request */ constructor(message = "AUTHENTICATION_ERROR", code = "AUTHENTICATION_ERROR_CODE", response, url, options) { super(message, code, response, url, options); this.name = "ArcGISAuthError"; this.message = code === "AUTHENTICATION_ERROR_CODE" ? message : `${code}: ${message}`; // restore prototype chain, see https://stackoverflow.com/questions/41102060/typescript-extending-error-class // we don't need to check for Object.setPrototypeOf as in the answers because we are ES2017 now. // Also see https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work // and https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#custom_error_types const actualProto = new.target.prototype; Object.setPrototypeOf(this, actualProto); } retry(getSession, retryLimit = 1) { let tries = 0; const retryRequest = (resolve, reject) => { tries = tries + 1; getSession(this.url, this.options) .then((session) => { const newOptions = Object.assign(Object.assign({}, this.options), { authentication: session }); return internalRequest(this.url, newOptions); }) .then((response) => { resolve(response); }) .catch((e) => { if (e.name === "ArcGISAuthError" && tries < retryLimit) { retryRequest(resolve, reject); } else if (e.name === this.name && e.message === this.message && tries >= retryLimit) { reject(this); } else { reject(e); } }); }; return new Promise((resolve, reject) => { retryRequest(resolve, reject); }); } } /** * Checks for errors in a JSON response from the ArcGIS REST API. If there are no errors, it will return the `data` passed in. If there is an error, it will throw an `ArcGISRequestError` or `ArcGISAuthError`. * * @param data The response JSON to check for errors. * @param url The url of the original request * @param params The parameters of the original request * @param options The options of the original request * @returns The data that was passed in the `data` parameter */ export function checkForErrors(response, url, params, options, originalAuthError) { // this is an error message from billing.arcgis.com backend if (response.code >= 400) { const { message, code } = response; throw new ArcGISRequestError(message, code, response, url, options); } // error from ArcGIS Online or an ArcGIS Portal or server instance. if (response.error) { const { message, code, messageCode } = response.error; const errorCode = messageCode || code || "UNKNOWN_ERROR_CODE"; if (code === 498 || code === 499) { if (originalAuthError) { throw originalAuthError; } else { throw new ArcGISAuthError(message, errorCode, response, url, options); } } throw new ArcGISRequestError(message, errorCode, response, url, options); } // error from a status check if (response.status === "failed" || response.status === "failure") { let message; let code = "UNKNOWN_ERROR_CODE"; try { message = JSON.parse(response.statusMessage).message; code = JSON.parse(response.statusMessage).code; } catch (e) { message = response.statusMessage || response.message; } throw new ArcGISRequestError(message, code, response, url, options); } return response; } /** * This is the internal implementation of `request` without the automatic retry behavior to prevent * infinite loops when a server continues to return invalid token errors. * * @param url - The URL of the ArcGIS REST API endpoint. * @param requestOptions - Options for the request, including parameters relevant to the endpoint. * @returns A Promise that will resolve with the data from the response. * @internal */ export function internalRequest(url, requestOptions) { const defaults = getDefaultRequestOptions(); const options = Object.assign(Object.assign(Object.assign({ httpMethod: "POST" }, defaults), requestOptions), { params: Object.assign(Object.assign({}, defaults.params), requestOptions.params), headers: Object.assign(Object.assign({}, defaults.headers), requestOptions.headers) }); const { httpMethod, rawResponse } = options; const params = Object.assign({ f: "json" }, options.params); let originalAuthError = null; const fetchOptions = { method: httpMethod, signal: options.signal, /* ensures behavior mimics XMLHttpRequest. needed to support sending IWA cookies */ credentials: options.credentials || "same-origin" }; // Is this a no-cors domain? if so we need to set credentials to include if (isNoCorsDomain(url)) { fetchOptions.credentials = "include"; } // the /oauth2/platformSelf route will add X-Esri-Auth-Client-Id header // and that request needs to send cookies cross domain // so we need to set the credentials to "include" if (options.headers && options.headers["X-Esri-Auth-Client-Id"] && url.indexOf("/oauth2/platformSelf") > -1) { fetchOptions.credentials = "include"; } let authentication; // Check to see if this is a raw token as a string and create a IAuthenticationManager like object for it. // Otherwise this just assumes that options.authentication is an IAuthenticationManager. if (typeof options.authentication === "string") { const rawToken = options.authentication; authentication = { portal: "https://www.arcgis.com/sharing/rest", getToken: () => { return Promise.resolve(rawToken); } }; /* istanbul ignore else - we don't need to test NOT warning people */ if (!options.authentication.startsWith("AAPK") && !options.authentication.startsWith("AATK") && // doesn't look like an API Key !options.suppressWarnings && // user doesn't want to suppress warnings for this request !globalThis.ARCGIS_REST_JS_SUPPRESS_TOKEN_WARNING // we haven't shown the user this warning yet ) { warn(`Using an oAuth 2.0 access token directly in the token option is discouraged. Consider using ArcGISIdentityManager or Application session. See https://esriurl.com/arcgis-rest-js-direct-token-warning for more information.`); globalThis.ARCGIS_REST_JS_SUPPRESS_TOKEN_WARNING = true; } } else { authentication = options.authentication; } // for errors in GET requests we want the URL passed to the error to be the URL before // query params are applied. const originalUrl = url; // default to false, for nodejs let sameOrigin = false; // if we are in a browser, check if the url is same origin /* istanbul ignore else */ if (typeof window !== "undefined") { sameOrigin = isSameOrigin(url); } const requiresNoCors = !sameOrigin && isNoCorsRequestRequired(url); // the /oauth2/platformSelf route will add X-Esri-Auth-Client-Id header // and that request needs to send cookies cross domain // so we need to set the credentials to "include" if (options.headers && options.headers["X-Esri-Auth-Client-Id"] && url.indexOf("/oauth2/platformSelf") > -1) { fetchOptions.credentials = "include"; } // Simple first promise that we may turn into the no-cors request let firstPromise = Promise.resolve(); if (requiresNoCors) { // ensure we send cookies on the request after fetchOptions.credentials = "include"; firstPromise = sendNoCorsRequest(url); } return firstPromise .then(() => authentication ? authentication.getToken(url).catch((err) => { /** * append original request url and requestOptions * to the error thrown by getToken() * to assist with retrying */ err.url = url; err.options = options; /** * if an attempt is made to talk to an unfederated server * first try the request anonymously. if a 'token required' * error is thrown, throw the UNFEDERATED error then. */ originalAuthError = err; return Promise.resolve(""); }) : Promise.resolve("")) .then((token) => { if (token.length) { params.token = token; } if (authentication && authentication.getDomainCredentials) { fetchOptions.credentials = authentication.getDomainCredentials(url); } // Custom headers to add to request. IRequestOptions.headers with merge over requestHeaders. const requestHeaders = {}; if (fetchOptions.method === "GET") { // Prevents token from being passed in query params when hideToken option is used. /* istanbul ignore if - window is always defined in a browser. Test case is covered by Jasmine in node test */ if (params.token && options.hideToken && // Sharing API does not support preflight check required by modern browsers https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request typeof window === "undefined") { requestHeaders["X-Esri-Authorization"] = `Bearer ${params.token}`; delete params.token; } // encode the parameters into the query string const queryParams = encodeQueryString(params); // dont append a '?' unless parameters are actually present const urlWithQueryString = queryParams === "" ? url : url + "?" + encodeQueryString(params); if ( // This would exceed the maximum length for URLs by 2000 as default or as specified by the consumer and requires POST (options.maxUrlLength && urlWithQueryString.length > options.maxUrlLength) || (!options.maxUrlLength && urlWithQueryString.length > 2000) || // Or if the customer requires the token to be hidden and it has not already been hidden in the header (for browsers) (params.token && options.hideToken)) { // the consumer specified a maximum length for URLs // and this would exceed it, so use post instead fetchOptions.method = "POST"; // If the token was already added as a Auth header, add the token back to body with other params instead of header if (token.length && options.hideToken) { params.token = token; // Remove existing header that was added before url query length was checked delete requestHeaders["X-Esri-Authorization"]; } } else { // just use GET url = urlWithQueryString; } } /* updateResources currently requires FormData even when the input parameters dont warrant it. https://developers.arcgis.com/rest/users-groups-and-items/update-resources.htm see https://github.com/Esri/arcgis-rest-js/pull/500 for more info. */ const forceFormData = new RegExp("/items/.+/updateResources").test(url); if (fetchOptions.method === "POST") { fetchOptions.body = encodeFormData(params, forceFormData); } // Mixin headers from request options fetchOptions.headers = Object.assign(Object.assign({}, requestHeaders), options.headers); // This should have the same conditional for Node JS as ArcGISIdentityManager.refreshWithUsernameAndPassword() // to ensure that generated tokens have the same referer when used in Node with a username and password. /* istanbul ignore next - karma reports coverage on browser tests only */ if ((typeof window === "undefined" || (window && typeof window.document === "undefined")) && !fetchOptions.headers.referer) { fetchOptions.headers.referer = NODEJS_DEFAULT_REFERER_HEADER; } /* istanbul ignore else blob responses are difficult to make cross platform we will just have to trust the isomorphic fetch will do its job */ if (!requiresFormData(params) && !forceFormData) { fetchOptions.headers["Content-Type"] = "application/x-www-form-urlencoded"; } /** * Check for a global fetch first and use it if available. This allows us to use the default * configuration of fetch-mock in tests. */ /* istanbul ignore next coverage is based on browser code and we don't test for the absence of global fetch so we can skip the else here. */ return globalThis.fetch ? globalThis.fetch(url, fetchOptions) : getFetch().then(({ fetch }) => { return fetch(url, fetchOptions); }); }) .then((response) => { // the request got back an error status code (4xx, 5xx) if (!response.ok) { // we need to determine if the server returned a JSON body with more details. // this is the format used by newer services such as the Places and Style service. return response .json() .then((jsonError) => { // The body can be parsed as JSON const { status, statusText } = response; const { message, details } = jsonError.error; const formattedMessage = `${message}. ${details ? details.join(" ") : ""}`.trim(); throw new ArcGISRequestError(formattedMessage, `HTTP ${status} ${statusText}`, jsonError, url, options); }) .catch((e) => { // if we already were about to format this as an ArcGISRequestError throw that error if (e.name === "ArcGISRequestError") { throw e; } // server responded w/ an actual error (404, 500, etc) but we could not parse it as JSON const { status, statusText } = response; throw new ArcGISRequestError(statusText, `HTTP ${status}`, response, url, options); }); } if (rawResponse) { return response; } switch (params.f) { case "json": return response.json(); case "geojson": return response.json(); case "html": return response.text(); case "text": return response.text(); /* istanbul ignore next blob responses are difficult to make cross platform we will just have to trust that isomorphic fetch will do its job */ default: return response.blob(); } }) .then((data) => { // Check for an error in the JSON body of a successful response. // Most ArcGIS Server services will return a successful status code but include an error in the response body. if ((params.f === "json" || params.f === "geojson") && !rawResponse) { const response = checkForErrors(data, originalUrl, params, options, originalAuthError); // If this was a portal/self call, and we got authorizedNoCorsDomains back // register them if (data && /\/sharing\/rest\/(accounts|portals)\/self/i.test(url)) { // if we have a list of no-cors domains, register them if (Array.isArray(data.authorizedCrossOriginNoCorsDomains)) { registerNoCorsDomains(data.authorizedCrossOriginNoCorsDomains); } } if (originalAuthError) { /* If the request was made to an unfederated service that didn't require authentication, add the base url and a dummy token to the list of trusted servers to avoid another federation check in the event of a repeat request */ const truncatedUrl = url .toLowerCase() .split(/\/rest(\/admin)?\/services\//)[0]; options.authentication.federatedServers[truncatedUrl] = { token: [], // default to 24 hours expires: new Date(Date.now() + 86400 * 1000) }; originalAuthError = null; } return response; } else { return data; } }); } /** * Generic method for making HTTP requests to ArcGIS REST API endpoints. * * ```js * import { request } from '@esri/arcgis-rest-request'; * * request('https://www.arcgis.com/sharing/rest') * .then(response) // response.currentVersion === 5.2 * * request('https://www.arcgis.com/sharing/rest', { * httpMethod: "GET" * }) * * request('https://www.arcgis.com/sharing/rest/search', { * params: { q: 'parks' } * }) * .then(response) // response.total => 78379 * ``` * * @param url - The URL of the ArcGIS REST API endpoint. * @param requestOptions - Options for the request, including parameters relevant to the endpoint. * @returns A Promise that will resolve with the data from the response. */ export function request(url, requestOptions = { params: { f: "json" } }) { const { request } = requestOptions, internalOptions = __rest(requestOptions, ["request"]); // if the user passed in a custom request function, use that instead of the default return request ? request(url, internalOptions) : internalRequest(url, internalOptions).catch((e) => { if (e instanceof ArcGISAuthError && requestOptions.authentication && typeof requestOptions.authentication !== "string" && requestOptions.authentication.canRefresh && requestOptions.authentication.refreshCredentials) { return e.retry(() => { return requestOptions.authentication.refreshCredentials(); }, 1); } else { return Promise.reject(e); } }); } //# sourceMappingURL=request.js.map