UNPKG

10.6 kBJavaScriptView Raw
1import M2ApiResponseError from './M2ApiResponseError';
2import * as MulticastCache from './MulticastCache';
3import { BrowserPersistence } from '../../util/';
4
5// TODO: headers are locked right now, add configurability
6const withDefaultHeaders = headerAdditions => {
7 const headers = new Headers({
8 'Content-type': 'application/json',
9 Accept: 'application/json'
10 });
11 if (headerAdditions) {
12 if (headerAdditions instanceof Headers) {
13 /* istanbul ignore next: current phantomJS doesn't support */
14 if (headerAdditions.entries) {
15 for (const [name, value] of headerAdditions) {
16 headers.append(name, value);
17 }
18 } else if (headerAdditions.forEach) {
19 // cover legacy case for old test environments
20 headerAdditions.forEach((name, value) => {
21 headers.append(name, value);
22 });
23 /* istanbul ignore next: should never happen, trivial to test*/
24 } else {
25 console.warn(
26 'Could not use headers object supplied to M2ApiRequest',
27 headerAdditions
28 );
29 }
30 } else {
31 for (const [name, value] of Object.entries(headerAdditions)) {
32 headers.append(name, value);
33 }
34 }
35 }
36 return headers;
37};
38
39/**
40 * All [fetch options](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) are passed through, with the addition of:
41 * @typedef {Object} M2ApiRequestOptions
42 * @property {boolean} [multicast] Override default multicast detection
43 */
44
45/**
46 * A request to the Magento 2 REST API. Returns a Promise created by a network
47 * fetch, but can potentially reuse prior requests if they qualify for
48 * multicast. Can abort an outstanding fetch request.
49 *
50 * @param {M2ApiRequestOptions} opts - All other [fetch options](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) will be passed through to `fetch`.
51 */
52class M2ApiRequest {
53 constructor(resourceUrl, opts = {}) {
54 const storage = new BrowserPersistence();
55 const signin_token = storage.getItem('signin_token');
56 this.controller = new AbortController();
57 this.resourceUrl = resourceUrl;
58 // merge headers specially
59 this.opts = {
60 // can be overridden
61 method: 'GET',
62 signal: this.controller.signal,
63 credentials: 'include',
64 ...opts,
65 // cannot be overridden, only appended to
66 headers: withDefaultHeaders(
67 new Headers({
68 authorization: signin_token ? `Bearer ${signin_token}` : ''
69 })
70 )
71 };
72 }
73 /**
74 * Execute the request. Must be run before {@link M2ApiRequest#getResponse}
75 * or {@link M2ApiRequest#cancel} can be called.
76 */
77 run() {
78 if (this._isMulticastable()) {
79 this._promise = this._fetchMulticast();
80 } else {
81 this._promise = this._fetch();
82 }
83 }
84 /**
85 * Get the promise for the network operation. Can only be called after
86 * `.run()` is called.
87 * For multicast requests, will return a promise for a new copy of the
88 * response every time it is called, since a Body can only be used once.
89 * Exists so that requests can reuse the promises from other requests.
90 * @returns {Promise} Promise for the result of the request.
91 */
92 getResponse() {
93 if (!this._promise) {
94 throw new Error(
95 'M2ApiRequest#getResponse() called before M2ApiRequest#run(), so no promise exists yet'
96 );
97 }
98 if (this._isMulticastable()) {
99 return this._promise.then(res => res.clone());
100 } else {
101 return this._promise;
102 }
103 }
104 /**
105 * Abort the network operation. Multicasted requests catch the AbortError
106 * and attempt to reuse a more recent matching request from cache. Other
107 * requests will pass the AbortError rejection through to the consumer.
108 */
109 abortRequest() {
110 this.controller.abort();
111 }
112 /**
113 * Check if this request intends to override prior requests to the same
114 * resource. Rolling requests will take the place of prior outstanding
115 * requests, to ensure the freshest resource at the cost of additional
116 * network calls.
117 *
118 * The current logic for rolling requests is determined by the `cache`
119 * option. [Cache modes](https://developer.mozilla.org/en-US/docs/Web/API/Request/cache)
120 * `reload` and `no-store` both indicate complete cache bypass. This
121 * logically implies that the user has just changed server state and wants
122 * to force retrieve an updated resource, so multicasting a prior request
123 * would not be appropriate--the response may not reflect the more recent
124 * change.
125 * @returns {boolean} True if the request is rolling.
126 */
127 isRolling() {
128 return this.opts.cache === 'no-store' || this.opts.cache === 'reload';
129 }
130 /**
131 * Make sure not to multicast POST requests which have a nonempty body,
132 * since they are unsafe and non-idempotent, so each call may mutate
133 * server-side state.
134 *
135 * In the M2 REST API, some POST requests have no body, and those tend
136 * to be idempotent.
137 *
138 * The `multicast` boolean option to the constructor can be used to
139 * override this, either to force `false` or `true`.
140 *
141 * @private
142 */
143 _isMulticastable() {
144 return this.opts.hasOwnProperty('multicast')
145 ? this.opts.multicast
146 : !(this.opts.method === 'POST' && this.opts.body);
147 }
148 /**
149 * Use the Fetch API to place a request to the M2 REST API.
150 * Exposed on prototype for testing only.
151 * @private
152 */
153 /* istanbul ignore next */
154 _transport(...args) {
155 return window.fetch(...args);
156 }
157 /**
158 * Use the AbortController API to make a cancelable fetch request.
159 * Reject on HTTP errors.
160 * @private
161 */
162 _fetch() {
163 return this._transport(this.resourceUrl, this.opts)
164 .then(
165 // When the network operation completes, remove from cache
166 // as a side effect.
167 res => {
168 MulticastCache.remove(this);
169 return res;
170 },
171 e => {
172 MulticastCache.remove(this);
173 throw e;
174 }
175 )
176 .then(response => {
177 // WHATWG fetch will only reject in the unlikely event
178 // of an error prior to opening the HTTP request.
179 // It pays no attention to HTTP status codes.
180 // But the response object does have an `ok` boolean
181 // corresponding to status codes in the 2xx range.
182 // An M2ApiRequest will reject, passing server errors
183 // to the client, in the event of an HTTP error code.
184 if (!response.ok) {
185 return (
186 response
187 // The response may or may not be JSON.
188 // Let M2ApiResponseError handle it.
189 .text()
190 // Throw a specially formatted error which
191 // includes the original context of the request,
192 // and formats the server response.
193 .then(bodyText => {
194 throw new M2ApiResponseError({
195 method: this.opts.method,
196 resourceUrl: this.resourceUrl,
197 response,
198 bodyText
199 });
200 })
201 );
202 }
203 return response;
204 });
205 }
206 /**
207 * Get a network operation matching this request, either by finding
208 * one in the MulticastCache, or by launching a new one (and caching
209 * it in the MulticastCache).
210 * @private
211 */
212 _fetchMulticast() {
213 // Does an inflight request exist that could be reused here?
214 // That is, does it have the same method, resourceUrl, and body and it
215 // appears idempotent and safe ?
216 const inflightMatch = MulticastCache.match(this);
217
218 // Is this request meant to override an existing inflight request?
219 const rolling = this.isRolling();
220 if (inflightMatch && !rolling) {
221 // Reuse the request!
222 return inflightMatch.getResponse();
223 }
224
225 // Either there is no match, or this is a rolling request
226 // and we must override the match.
227
228 // Cache this request for future use.
229 MulticastCache.store(this);
230
231 const promise = this._fetch().catch(error => {
232 // Rolling requests cause prior matching requests to abort.
233 // Their consumers will get an unexpected error unless we
234 // swallow the AbortError we expect, and replace it with
235 // the promise from our rolling request.
236 if (error.name === 'AbortError') {
237 const replacedInFlightMatch = MulticastCache.match(this);
238 if (replacedInFlightMatch) {
239 // There is a rolling request in the cache to override!
240 return replacedInFlightMatch.getResponse();
241 }
242 }
243 throw error;
244 });
245
246 if (rolling && inflightMatch) {
247 inflightMatch.abortRequest();
248 }
249
250 return promise;
251 }
252}
253
254export default M2ApiRequest;
255
256/**
257 * Place a request to the Magento 2 REST API and return a Promise for the
258 * response.
259 * @param (string) resourceUrl The URL of the resource to request.
260 * @param {M2ApiRequestOptions} opts Options to be passed to [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters), with the addition of the `multicast` option.
261 * @returns {Promise} A promise for the parsed REST request.
262 */
263export function request(resourceUrl, opts) {
264 const req = new M2ApiRequest(resourceUrl, opts);
265
266 req.run();
267
268 const promise = req.getResponse();
269
270 if (opts && opts.parseJSON === false) {
271 return promise;
272 }
273 return promise.then(res => res.json());
274}