1 | import M2ApiResponseError from './M2ApiResponseError';
|
2 | import * as MulticastCache from './MulticastCache';
|
3 | import { BrowserPersistence } from '../../util/';
|
4 |
|
5 | // TODO: headers are locked right now, add configurability
|
6 | const 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 | */
|
52 | class 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 |
|
254 | export 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 | */
|
263 | export 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 | }
|