UNPKG

42 kBJavaScriptView Raw
1/* globals najax jQuery */
2
3/**
4 @module @ember-data/adapter
5*/
6
7import { getOwner } from '@ember/application';
8import { warn } from '@ember/debug';
9import { computed, get } from '@ember/object';
10import { assign } from '@ember/polyfills';
11import { run } from '@ember/runloop';
12import { DEBUG } from '@glimmer/env';
13
14import { Promise } from 'rsvp';
15
16import Adapter, { BuildURLMixin } from '@ember-data/adapter';
17import AdapterError, {
18 AbortError,
19 ConflictError,
20 ForbiddenError,
21 InvalidError,
22 NotFoundError,
23 ServerError,
24 TimeoutError,
25 UnauthorizedError,
26} from '@ember-data/adapter/error';
27
28import { determineBodyPromise, fetch, parseResponseHeaders, serializeIntoHash, serializeQueryParams } from './-private';
29
30const hasJQuery = typeof jQuery !== 'undefined';
31const hasNajax = typeof najax !== 'undefined';
32
33/**
34 The REST adapter allows your store to communicate with an HTTP server by
35 transmitting JSON via XHR. Most Ember.js apps that consume a JSON API
36 should use the REST adapter.
37
38 This adapter is designed around the idea that the JSON exchanged with
39 the server should be conventional.
40
41 ## Success and failure
42
43 The REST adapter will consider a success any response with a status code
44 of the 2xx family ("Success"), as well as 304 ("Not Modified"). Any other
45 status code will be considered a failure.
46
47 On success, the request promise will be resolved with the full response
48 payload.
49
50 Failed responses with status code 422 ("Unprocessable Entity") will be
51 considered "invalid". The response will be discarded, except for the
52 `errors` key. The request promise will be rejected with a `InvalidError`.
53 This error object will encapsulate the saved `errors` value.
54
55 Any other status codes will be treated as an "adapter error". The request
56 promise will be rejected, similarly to the "invalid" case, but with
57 an instance of `AdapterError` instead.
58
59 ## JSON Structure
60
61 The REST adapter expects the JSON returned from your server to follow
62 these conventions.
63
64 ### Object Root
65
66 The JSON payload should be an object that contains the record inside a
67 root property. For example, in response to a `GET` request for
68 `/posts/1`, the JSON should look like this:
69
70 ```js
71 {
72 "posts": {
73 "id": 1,
74 "title": "I'm Running to Reform the W3C's Tag",
75 "author": "Yehuda Katz"
76 }
77 }
78 ```
79
80 Similarly, in response to a `GET` request for `/posts`, the JSON should
81 look like this:
82
83 ```js
84 {
85 "posts": [
86 {
87 "id": 1,
88 "title": "I'm Running to Reform the W3C's Tag",
89 "author": "Yehuda Katz"
90 },
91 {
92 "id": 2,
93 "title": "Rails is omakase",
94 "author": "D2H"
95 }
96 ]
97 }
98 ```
99
100 Note that the object root can be pluralized for both a single-object response
101 and an array response: the REST adapter is not strict on this. Further, if the
102 HTTP server responds to a `GET` request to `/posts/1` (e.g. the response to a
103 `findRecord` query) with more than one object in the array, Ember Data will
104 only display the object with the matching ID.
105
106 ### Conventional Names
107
108 Attribute names in your JSON payload should be the camelCased versions of
109 the attributes in your Ember.js models.
110
111 For example, if you have a `Person` model:
112
113 ```app/models/person.js
114 import Model, { attr } from '@ember-data/model';
115
116 export default Model.extend({
117 firstName: attr('string'),
118 lastName: attr('string'),
119 occupation: attr('string')
120 });
121 ```
122
123 The JSON returned should look like this:
124
125 ```js
126 {
127 "people": {
128 "id": 5,
129 "firstName": "Zaphod",
130 "lastName": "Beeblebrox",
131 "occupation": "President"
132 }
133 }
134 ```
135
136 #### Relationships
137
138 Relationships are usually represented by ids to the record in the
139 relationship. The related records can then be sideloaded in the
140 response under a key for the type.
141
142 ```js
143 {
144 "posts": {
145 "id": 5,
146 "title": "I'm Running to Reform the W3C's Tag",
147 "author": "Yehuda Katz",
148 "comments": [1, 2]
149 },
150 "comments": [{
151 "id": 1,
152 "author": "User 1",
153 "message": "First!",
154 }, {
155 "id": 2,
156 "author": "User 2",
157 "message": "Good Luck!",
158 }]
159 }
160 ```
161
162 If the records in the relationship are not known when the response
163 is serialized it's also possible to represent the relationship as a
164 URL using the `links` key in the response. Ember Data will fetch
165 this URL to resolve the relationship when it is accessed for the
166 first time.
167
168 ```js
169 {
170 "posts": {
171 "id": 5,
172 "title": "I'm Running to Reform the W3C's Tag",
173 "author": "Yehuda Katz",
174 "links": {
175 "comments": "/posts/5/comments"
176 }
177 }
178 }
179 ```
180
181 ### Errors
182
183 If a response is considered a failure, the JSON payload is expected to include
184 a top-level key `errors`, detailing any specific issues. For example:
185
186 ```js
187 {
188 "errors": {
189 "msg": "Something went wrong"
190 }
191 }
192 ```
193
194 This adapter does not make any assumptions as to the format of the `errors`
195 object. It will simply be passed along as is, wrapped in an instance
196 of `InvalidError` or `AdapterError`. The serializer can interpret it
197 afterwards.
198
199 ## Customization
200
201 ### Endpoint path customization
202
203 Endpoint paths can be prefixed with a `namespace` by setting the namespace
204 property on the adapter:
205
206 ```app/adapters/application.js
207 import RESTAdapter from '@ember-data/adapter/rest';
208
209 export default RESTAdapter.extend({
210 namespace: 'api/1'
211 });
212 ```
213 Requests for the `Person` model would now target `/api/1/people/1`.
214
215 ### Host customization
216
217 An adapter can target other hosts by setting the `host` property.
218
219 ```app/adapters/application.js
220 import RESTAdapter from '@ember-data/adapter/rest';
221
222 export default RESTAdapter.extend({
223 host: 'https://api.example.com'
224 });
225 ```
226
227 ### Headers customization
228
229 Some APIs require HTTP headers, e.g. to provide an API key. Arbitrary
230 headers can be set as key/value pairs on the `RESTAdapter`'s `headers`
231 object and Ember Data will send them along with each ajax request.
232
233
234 ```app/adapters/application.js
235 import RESTAdapter from '@ember-data/adapter/rest';
236 import { computed } from '@ember/object';
237
238 export default RESTAdapter.extend({
239 headers: computed(function() {
240 return {
241 'API_KEY': 'secret key',
242 'ANOTHER_HEADER': 'Some header value'
243 };
244 }
245 });
246 ```
247
248 `headers` can also be used as a computed property to support dynamic
249 headers. In the example below, the `session` object has been
250 injected into an adapter by Ember's container.
251
252 ```app/adapters/application.js
253 import RESTAdapter from '@ember-data/adapter/rest';
254 import { computed } from '@ember/object';
255
256 export default RESTAdapter.extend({
257 headers: computed('session.authToken', function() {
258 return {
259 'API_KEY': this.get('session.authToken'),
260 'ANOTHER_HEADER': 'Some header value'
261 };
262 })
263 });
264 ```
265
266 In some cases, your dynamic headers may require data from some
267 object outside of Ember's observer system (for example
268 `document.cookie`). You can use the
269 [volatile](/api/classes/Ember.ComputedProperty.html?anchor=volatile)
270 function to set the property into a non-cached mode causing the headers to
271 be recomputed with every request.
272
273 ```app/adapters/application.js
274 import RESTAdapter from '@ember-data/adapter/rest';
275 import { get } from '@ember/object';
276 import { computed } from '@ember/object';
277
278 export default RESTAdapter.extend({
279 headers: computed(function() {
280 return {
281 'API_KEY': get(document.cookie.match(/apiKey\=([^;]*)/), '1'),
282 'ANOTHER_HEADER': 'Some header value'
283 };
284 }).volatile()
285 });
286 ```
287
288 @class RESTAdapter
289 @constructor
290 @extends Adapter
291 @uses BuildURLMixin
292*/
293const RESTAdapter = Adapter.extend(BuildURLMixin, {
294 defaultSerializer: '-rest',
295
296 _defaultContentType: 'application/json; charset=utf-8',
297
298 fastboot: computed({
299 // Avoid computed property override deprecation in fastboot as suggested by:
300 // https://deprecations.emberjs.com/v3.x/#toc_computed-property-override
301 get() {
302 if (this._fastboot) {
303 return this._fastboot;
304 }
305 return (this._fastboot = getOwner(this).lookup('service:fastboot'));
306 },
307 set(key, value) {
308 return (this._fastboot = value);
309 },
310 }),
311
312 useFetch: computed(function() {
313 let ENV = getOwner(this).resolveRegistration('config:environment');
314 // TODO: https://github.com/emberjs/data/issues/6093
315 let jQueryIntegrationDisabled = ENV && ENV.EmberENV && ENV.EmberENV._JQUERY_INTEGRATION === false;
316
317 if (jQueryIntegrationDisabled) {
318 return true;
319 } else if (hasNajax || hasJQuery) {
320 return false;
321 } else {
322 return true;
323 }
324 }),
325
326 /**
327 By default, the RESTAdapter will send the query params sorted alphabetically to the
328 server.
329
330 For example:
331
332 ```js
333 store.query('posts', { sort: 'price', category: 'pets' });
334 ```
335
336 will generate a requests like this `/posts?category=pets&sort=price`, even if the
337 parameters were specified in a different order.
338
339 That way the generated URL will be deterministic and that simplifies caching mechanisms
340 in the backend.
341
342 Setting `sortQueryParams` to a falsey value will respect the original order.
343
344 In case you want to sort the query parameters with a different criteria, set
345 `sortQueryParams` to your custom sort function.
346
347 ```app/adapters/application.js
348 import RESTAdapter from '@ember-data/adapter/rest';
349
350 export default RESTAdapter.extend({
351 sortQueryParams(params) {
352 let sortedKeys = Object.keys(params).sort().reverse();
353 let len = sortedKeys.length, newParams = {};
354
355 for (let i = 0; i < len; i++) {
356 newParams[sortedKeys[i]] = params[sortedKeys[i]];
357 }
358
359 return newParams;
360 }
361 });
362 ```
363
364 @method sortQueryParams
365 @param {Object} obj
366 @return {Object}
367 */
368 sortQueryParams(obj) {
369 let keys = Object.keys(obj);
370 let len = keys.length;
371 if (len < 2) {
372 return obj;
373 }
374 let newQueryParams = {};
375 let sortedKeys = keys.sort();
376
377 for (let i = 0; i < len; i++) {
378 newQueryParams[sortedKeys[i]] = obj[sortedKeys[i]];
379 }
380 return newQueryParams;
381 },
382
383 /**
384 By default the RESTAdapter will send each find request coming from a `store.find`
385 or from accessing a relationship separately to the server. If your server supports passing
386 ids as a query string, you can set coalesceFindRequests to true to coalesce all find requests
387 within a single runloop.
388
389 For example, if you have an initial payload of:
390
391 ```javascript
392 {
393 post: {
394 id: 1,
395 comments: [1, 2]
396 }
397 }
398 ```
399
400 By default calling `post.get('comments')` will trigger the following requests(assuming the
401 comments haven't been loaded before):
402
403 ```
404 GET /comments/1
405 GET /comments/2
406 ```
407
408 If you set coalesceFindRequests to `true` it will instead trigger the following request:
409
410 ```
411 GET /comments?ids[]=1&ids[]=2
412 ```
413
414 Setting coalesceFindRequests to `true` also works for `store.find` requests and `belongsTo`
415 relationships accessed within the same runloop. If you set `coalesceFindRequests: true`
416
417 ```javascript
418 store.findRecord('comment', 1);
419 store.findRecord('comment', 2);
420 ```
421
422 will also send a request to: `GET /comments?ids[]=1&ids[]=2`
423
424 Note: Requests coalescing rely on URL building strategy. So if you override `buildURL` in your app
425 `groupRecordsForFindMany` more likely should be overridden as well in order for coalescing to work.
426
427 @property coalesceFindRequests
428 @type {boolean}
429 */
430 coalesceFindRequests: false,
431
432 /**
433 Endpoint paths can be prefixed with a `namespace` by setting the namespace
434 property on the adapter:
435
436 ```app/adapters/application.js
437 import RESTAdapter from '@ember-data/adapter/rest';
438
439 export default RESTAdapter.extend({
440 namespace: 'api/1'
441 });
442 ```
443
444 Requests for the `Post` model would now target `/api/1/post/`.
445
446 @property namespace
447 @type {String}
448 */
449
450 /**
451 An adapter can target other hosts by setting the `host` property.
452
453 ```app/adapters/application.js
454 import RESTAdapter from '@ember-data/adapter/rest';
455
456 export default RESTAdapter.extend({
457 host: 'https://api.example.com'
458 });
459 ```
460
461 Requests for the `Post` model would now target `https://api.example.com/post/`.
462
463 @property host
464 @type {String}
465 */
466
467 /**
468 Some APIs require HTTP headers, e.g. to provide an API
469 key. Arbitrary headers can be set as key/value pairs on the
470 `RESTAdapter`'s `headers` object and Ember Data will send them
471 along with each ajax request. For dynamic headers see [headers
472 customization](/ember-data/release/classes/RESTAdapter).
473
474 ```app/adapters/application.js
475 import RESTAdapter from '@ember-data/adapter/rest';
476 import { computed } from '@ember/object';
477
478 export default RESTAdapter.extend({
479 headers: computed(function() {
480 return {
481 'API_KEY': 'secret key',
482 'ANOTHER_HEADER': 'Some header value'
483 };
484 })
485 });
486 ```
487
488 @property headers
489 @type {Object}
490 */
491
492 /**
493 Called by the store in order to fetch the JSON for a given
494 type and ID.
495
496 The `findRecord` method makes an Ajax request to a URL computed by
497 `buildURL`, and returns a promise for the resulting payload.
498
499 This method performs an HTTP `GET` request with the id provided as part of the query string.
500
501 @since 1.13.0
502 @method findRecord
503 @param {Store} store
504 @param {Model} type
505 @param {String} id
506 @param {Snapshot} snapshot
507 @return {Promise} promise
508 */
509 findRecord(store, type, id, snapshot) {
510 let url = this.buildURL(type.modelName, id, snapshot, 'findRecord');
511 let query = this.buildQuery(snapshot);
512
513 return this.ajax(url, 'GET', { data: query });
514 },
515
516 /**
517 Called by the store in order to fetch a JSON array for all
518 of the records for a given type.
519
520 The `findAll` method makes an Ajax (HTTP GET) request to a URL computed by `buildURL`, and returns a
521 promise for the resulting payload.
522
523 @method findAll
524 @param {Store} store
525 @param {Model} type
526 @param {undefined} neverSet a value is never provided to this argument
527 @param {SnapshotRecordArray} snapshotRecordArray
528 @return {Promise} promise
529 */
530 findAll(store, type, sinceToken, snapshotRecordArray) {
531 let query = this.buildQuery(snapshotRecordArray);
532 let url = this.buildURL(type.modelName, null, snapshotRecordArray, 'findAll');
533
534 if (sinceToken) {
535 query.since = sinceToken;
536 }
537
538 return this.ajax(url, 'GET', { data: query });
539 },
540
541 /**
542 Called by the store in order to fetch a JSON array for
543 the records that match a particular query.
544
545 The `query` method makes an Ajax (HTTP GET) request to a URL
546 computed by `buildURL`, and returns a promise for the resulting
547 payload.
548
549 The `query` argument is a simple JavaScript object that will be passed directly
550 to the server as parameters.
551
552 @method query
553 @param {Store} store
554 @param {Model} type
555 @param {Object} query
556 @return {Promise} promise
557 */
558 query(store, type, query) {
559 let url = this.buildURL(type.modelName, null, null, 'query', query);
560
561 if (this.sortQueryParams) {
562 query = this.sortQueryParams(query);
563 }
564
565 return this.ajax(url, 'GET', { data: query });
566 },
567
568 /**
569 Called by the store in order to fetch a JSON object for
570 the record that matches a particular query.
571
572 The `queryRecord` method makes an Ajax (HTTP GET) request to a URL
573 computed by `buildURL`, and returns a promise for the resulting
574 payload.
575
576 The `query` argument is a simple JavaScript object that will be passed directly
577 to the server as parameters.
578
579 @since 1.13.0
580 @method queryRecord
581 @param {Store} store
582 @param {Model} type
583 @param {Object} query
584 @return {Promise} promise
585 */
586 queryRecord(store, type, query) {
587 let url = this.buildURL(type.modelName, null, null, 'queryRecord', query);
588
589 if (this.sortQueryParams) {
590 query = this.sortQueryParams(query);
591 }
592
593 return this.ajax(url, 'GET', { data: query });
594 },
595
596 /**
597 Called by the store in order to fetch several records together if `coalesceFindRequests` is true
598
599 For example, if the original payload looks like:
600
601 ```js
602 {
603 "id": 1,
604 "title": "Rails is omakase",
605 "comments": [ 1, 2, 3 ]
606 }
607 ```
608
609 The IDs will be passed as a URL-encoded Array of IDs, in this form:
610
611 ```
612 ids[]=1&ids[]=2&ids[]=3
613 ```
614
615 Many servers, such as Rails and PHP, will automatically convert this URL-encoded array
616 into an Array for you on the server-side. If you want to encode the
617 IDs, differently, just override this (one-line) method.
618
619 The `findMany` method makes an Ajax (HTTP GET) request to a URL computed by `buildURL`, and returns a
620 promise for the resulting payload.
621
622 @method findMany
623 @param {Store} store
624 @param {Model} type
625 @param {Array} ids
626 @param {Array} snapshots
627 @return {Promise} promise
628 */
629 findMany(store, type, ids, snapshots) {
630 let url = this.buildURL(type.modelName, ids, snapshots, 'findMany');
631 return this.ajax(url, 'GET', { data: { ids: ids } });
632 },
633
634 /**
635 Called by the store in order to fetch a JSON array for
636 the unloaded records in a has-many relationship that were originally
637 specified as a URL (inside of `links`).
638
639 For example, if your original payload looks like this:
640
641 ```js
642 {
643 "post": {
644 "id": 1,
645 "title": "Rails is omakase",
646 "links": { "comments": "/posts/1/comments" }
647 }
648 }
649 ```
650
651 This method will be called with the parent record and `/posts/1/comments`.
652
653 The `findHasMany` method will make an Ajax (HTTP GET) request to the originally specified URL.
654
655 The format of your `links` value will influence the final request URL via the `urlPrefix` method:
656
657 * Links beginning with `//`, `http://`, `https://`, will be used as is, with no further manipulation.
658
659 * Links beginning with a single `/` will have the current adapter's `host` value prepended to it.
660
661 * Links with no beginning `/` will have a parentURL prepended to it, via the current adapter's `buildURL`.
662
663 @method findHasMany
664 @param {Store} store
665 @param {Snapshot} snapshot
666 @param {String} url
667 @param {Object} relationship meta object describing the relationship
668 @return {Promise} promise
669 */
670 findHasMany(store, snapshot, url, relationship) {
671 let id = snapshot.id;
672 let type = snapshot.modelName;
673
674 url = this.urlPrefix(url, this.buildURL(type, id, snapshot, 'findHasMany'));
675
676 return this.ajax(url, 'GET');
677 },
678
679 /**
680 Called by the store in order to fetch the JSON for the unloaded record in a
681 belongs-to relationship that was originally specified as a URL (inside of
682 `links`).
683
684 For example, if your original payload looks like this:
685
686 ```js
687 {
688 "person": {
689 "id": 1,
690 "name": "Tom Dale",
691 "links": { "group": "/people/1/group" }
692 }
693 }
694 ```
695
696 This method will be called with the parent record and `/people/1/group`.
697
698 The `findBelongsTo` method will make an Ajax (HTTP GET) request to the originally specified URL.
699
700 The format of your `links` value will influence the final request URL via the `urlPrefix` method:
701
702 * Links beginning with `//`, `http://`, `https://`, will be used as is, with no further manipulation.
703
704 * Links beginning with a single `/` will have the current adapter's `host` value prepended to it.
705
706 * Links with no beginning `/` will have a parentURL prepended to it, via the current adapter's `buildURL`.
707
708 @method findBelongsTo
709 @param {Store} store
710 @param {Snapshot} snapshot
711 @param {String} url
712 @param {Object} relationship meta object describing the relationship
713 @return {Promise} promise
714 */
715 findBelongsTo(store, snapshot, url, relationship) {
716 let id = snapshot.id;
717 let type = snapshot.modelName;
718
719 url = this.urlPrefix(url, this.buildURL(type, id, snapshot, 'findBelongsTo'));
720 return this.ajax(url, 'GET');
721 },
722
723 /**
724 Called by the store when a newly created record is
725 saved via the `save` method on a model record instance.
726
727 The `createRecord` method serializes the record and makes an Ajax (HTTP POST) request
728 to a URL computed by `buildURL`.
729
730 See `serialize` for information on how to customize the serialized form
731 of a record.
732
733 @method createRecord
734 @param {Store} store
735 @param {Model} type
736 @param {Snapshot} snapshot
737 @return {Promise} promise
738 */
739 createRecord(store, type, snapshot) {
740 let url = this.buildURL(type.modelName, null, snapshot, 'createRecord');
741
742 const data = serializeIntoHash(store, type, snapshot);
743
744 return this.ajax(url, 'POST', { data });
745 },
746
747 /**
748 Called by the store when an existing record is saved
749 via the `save` method on a model record instance.
750
751 The `updateRecord` method serializes the record and makes an Ajax (HTTP PUT) request
752 to a URL computed by `buildURL`.
753
754 See `serialize` for information on how to customize the serialized form
755 of a record.
756
757 @method updateRecord
758 @param {Store} store
759 @param {Model} type
760 @param {Snapshot} snapshot
761 @return {Promise} promise
762 */
763 updateRecord(store, type, snapshot) {
764 const data = serializeIntoHash(store, type, snapshot, {});
765
766 let id = snapshot.id;
767 let url = this.buildURL(type.modelName, id, snapshot, 'updateRecord');
768
769 return this.ajax(url, 'PUT', { data });
770 },
771
772 /**
773 Called by the store when a record is deleted.
774
775 The `deleteRecord` method makes an Ajax (HTTP DELETE) request to a URL computed by `buildURL`.
776
777 @method deleteRecord
778 @param {Store} store
779 @param {Model} type
780 @param {Snapshot} snapshot
781 @return {Promise} promise
782 */
783 deleteRecord(store, type, snapshot) {
784 let id = snapshot.id;
785
786 return this.ajax(this.buildURL(type.modelName, id, snapshot, 'deleteRecord'), 'DELETE');
787 },
788
789 _stripIDFromURL(store, snapshot) {
790 let url = this.buildURL(snapshot.modelName, snapshot.id, snapshot);
791
792 let expandedURL = url.split('/');
793 // Case when the url is of the format ...something/:id
794 // We are decodeURIComponent-ing the lastSegment because if it represents
795 // the id, it has been encodeURIComponent-ified within `buildURL`. If we
796 // don't do this, then records with id having special characters are not
797 // coalesced correctly (see GH #4190 for the reported bug)
798 let lastSegment = expandedURL[expandedURL.length - 1];
799 let id = snapshot.id;
800 if (decodeURIComponent(lastSegment) === id) {
801 expandedURL[expandedURL.length - 1] = '';
802 } else if (endsWith(lastSegment, '?id=' + id)) {
803 //Case when the url is of the format ...something?id=:id
804 expandedURL[expandedURL.length - 1] = lastSegment.substring(0, lastSegment.length - id.length - 1);
805 }
806
807 return expandedURL.join('/');
808 },
809
810 // http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers
811 maxURLLength: 2048,
812
813 /**
814 Organize records into groups, each of which is to be passed to separate
815 calls to `findMany`.
816
817 This implementation groups together records that have the same base URL but
818 differing ids. For example `/comments/1` and `/comments/2` will be grouped together
819 because we know findMany can coalesce them together as `/comments?ids[]=1&ids[]=2`
820
821 It also supports urls where ids are passed as a query param, such as `/comments?id=1`
822 but not those where there is more than 1 query param such as `/comments?id=2&name=David`
823 Currently only the query param of `id` is supported. If you need to support others, please
824 override this or the `_stripIDFromURL` method.
825
826 It does not group records that have differing base urls, such as for example: `/posts/1/comments/2`
827 and `/posts/2/comments/3`
828
829 @method groupRecordsForFindMany
830 @param {Store} store
831 @param {Array} snapshots
832 @return {Array} an array of arrays of records, each of which is to be
833 loaded separately by `findMany`.
834 */
835 groupRecordsForFindMany(store, snapshots) {
836 let groups = new Map();
837 let adapter = this;
838 let maxURLLength = this.maxURLLength;
839
840 snapshots.forEach(snapshot => {
841 let baseUrl = adapter._stripIDFromURL(store, snapshot);
842 if (!groups.has(baseUrl)) {
843 groups.set(baseUrl, []);
844 }
845
846 groups.get(baseUrl).push(snapshot);
847 });
848
849 function splitGroupToFitInUrl(group, maxURLLength, paramNameLength) {
850 let idsSize = 0;
851 let baseUrl = adapter._stripIDFromURL(store, group[0]);
852 let splitGroups = [[]];
853
854 group.forEach(snapshot => {
855 let additionalLength = encodeURIComponent(snapshot.id).length + paramNameLength;
856 if (baseUrl.length + idsSize + additionalLength >= maxURLLength) {
857 idsSize = 0;
858 splitGroups.push([]);
859 }
860
861 idsSize += additionalLength;
862
863 let lastGroupIndex = splitGroups.length - 1;
864 splitGroups[lastGroupIndex].push(snapshot);
865 });
866
867 return splitGroups;
868 }
869
870 let groupsArray = [];
871 groups.forEach((group, key) => {
872 let paramNameLength = '&ids%5B%5D='.length;
873 let splitGroups = splitGroupToFitInUrl(group, maxURLLength, paramNameLength);
874
875 splitGroups.forEach(splitGroup => groupsArray.push(splitGroup));
876 });
877
878 return groupsArray;
879 },
880
881 /**
882 Takes an ajax response, and returns the json payload or an error.
883
884 By default this hook just returns the json payload passed to it.
885 You might want to override it in two cases:
886
887 1. Your API might return useful results in the response headers.
888 Response headers are passed in as the second argument.
889
890 2. Your API might return errors as successful responses with status code
891 200 and an Errors text or object. You can return a `InvalidError` or a
892 `AdapterError` (or a sub class) from this hook and it will automatically
893 reject the promise and put your record into the invalid or error state.
894
895 Returning a `InvalidError` from this method will cause the
896 record to transition into the `invalid` state and make the
897 `errors` object available on the record. When returning an
898 `InvalidError` the store will attempt to normalize the error data
899 returned from the server using the serializer's `extractErrors`
900 method.
901
902 @since 1.13.0
903 @method handleResponse
904 @param {Number} status
905 @param {Object} headers
906 @param {Object} payload
907 @param {Object} requestData - the original request information
908 @return {Object | AdapterError} response
909 */
910 handleResponse(status, headers, payload, requestData) {
911 if (this.isSuccess(status, headers, payload)) {
912 return payload;
913 } else if (this.isInvalid(status, headers, payload)) {
914 return new InvalidError(payload.errors);
915 }
916
917 let errors = this.normalizeErrorResponse(status, headers, payload);
918 let detailedMessage = this.generatedDetailedMessage(status, headers, payload, requestData);
919
920 switch (status) {
921 case 401:
922 return new UnauthorizedError(errors, detailedMessage);
923 case 403:
924 return new ForbiddenError(errors, detailedMessage);
925 case 404:
926 return new NotFoundError(errors, detailedMessage);
927 case 409:
928 return new ConflictError(errors, detailedMessage);
929 default:
930 if (status >= 500) {
931 return new ServerError(errors, detailedMessage);
932 }
933 }
934
935 return new AdapterError(errors, detailedMessage);
936 },
937
938 /**
939 Default `handleResponse` implementation uses this hook to decide if the
940 response is a success.
941
942 @since 1.13.0
943 @method isSuccess
944 @param {Number} status
945 @param {Object} headers
946 @param {Object} payload
947 @return {Boolean}
948 */
949 isSuccess(status, headers, payload) {
950 return (status >= 200 && status < 300) || status === 304;
951 },
952
953 /**
954 Default `handleResponse` implementation uses this hook to decide if the
955 response is an invalid error.
956
957 @since 1.13.0
958 @method isInvalid
959 @param {Number} status
960 @param {Object} headers
961 @param {Object} payload
962 @return {Boolean}
963 */
964 isInvalid(status, headers, payload) {
965 return status === 422;
966 },
967
968 /**
969 Takes a URL, an HTTP method and a hash of data, and makes an
970 HTTP request.
971
972 When the server responds with a payload, Ember Data will call into `extractSingle`
973 or `extractArray` (depending on whether the original query was for one record or
974 many records).
975
976 By default, `ajax` method has the following behavior:
977
978 * It sets the response `dataType` to `"json"`
979 * If the HTTP method is not `"GET"`, it sets the `Content-Type` to be
980 `application/json; charset=utf-8`
981 * If the HTTP method is not `"GET"`, it stringifies the data passed in. The
982 data is the serialized record in the case of a save.
983 * Registers success and failure handlers.
984
985 @method ajax
986 @private
987 @param {String} url
988 @param {String} type The request type GET, POST, PUT, DELETE etc.
989 @param {Object} options
990 @return {Promise} promise
991 */
992 ajax(url, type, options) {
993 let adapter = this;
994 let useFetch = get(this, 'useFetch');
995
996 let requestData = {
997 url: url,
998 method: type,
999 };
1000 let hash = adapter.ajaxOptions(url, type, options);
1001
1002 if (useFetch) {
1003 let _response;
1004 return this._fetchRequest(hash)
1005 .then(response => {
1006 _response = response;
1007 return determineBodyPromise(response, requestData);
1008 })
1009 .then(payload => {
1010 if (_response.ok && !(payload instanceof Error)) {
1011 return fetchSuccessHandler(adapter, payload, _response, requestData);
1012 } else {
1013 throw fetchErrorHandler(adapter, payload, _response, null, requestData);
1014 }
1015 });
1016 }
1017
1018 return new Promise(function(resolve, reject) {
1019 hash.success = function(payload, textStatus, jqXHR) {
1020 let response = ajaxSuccessHandler(adapter, payload, jqXHR, requestData);
1021 run.join(null, resolve, response);
1022 };
1023
1024 hash.error = function(jqXHR, textStatus, errorThrown) {
1025 let error = ajaxErrorHandler(adapter, jqXHR, errorThrown, requestData);
1026 run.join(null, reject, error);
1027 };
1028
1029 adapter._ajax(hash);
1030 }, 'DS: RESTAdapter#ajax ' + type + ' to ' + url);
1031 },
1032
1033 /**
1034 @method _ajaxRequest
1035 @private
1036 @param {Object} options jQuery ajax options to be used for the ajax request
1037 */
1038 _ajaxRequest(options) {
1039 jQuery.ajax(options);
1040 },
1041
1042 /**
1043 @method _najaxRequest
1044 @private
1045 @param {Object} options jQuery ajax options to be used for the najax request
1046 */
1047 _najaxRequest(options) {
1048 if (hasNajax) {
1049 najax(options);
1050 } else {
1051 throw new Error(
1052 'najax does not seem to be defined in your app. Did you override it via `addOrOverrideSandboxGlobals` in the fastboot server?'
1053 );
1054 }
1055 },
1056
1057 _fetchRequest(options) {
1058 let fetchFunction = fetch();
1059
1060 if (fetchFunction) {
1061 return fetchFunction(options.url, options);
1062 } else {
1063 throw new Error(
1064 'cannot find the `fetch` module or the `fetch` global. Did you mean to install the `ember-fetch` addon?'
1065 );
1066 }
1067 },
1068
1069 _ajax(options) {
1070 if (get(this, 'useFetch')) {
1071 this._fetchRequest(options);
1072 } else if (get(this, 'fastboot.isFastBoot')) {
1073 this._najaxRequest(options);
1074 } else {
1075 this._ajaxRequest(options);
1076 }
1077 },
1078
1079 /**
1080 @method ajaxOptions
1081 @private
1082 @param {String} url
1083 @param {String} type The request type GET, POST, PUT, DELETE etc.
1084 @param {Object} options
1085 @return {Object}
1086 */
1087 ajaxOptions(url, method, options) {
1088 options = assign(
1089 {
1090 url,
1091 method,
1092 type: method,
1093 },
1094 options
1095 );
1096
1097 let headers = get(this, 'headers');
1098 if (headers !== undefined) {
1099 options.headers = assign({}, headers, options.headers);
1100 } else if (!options.headers) {
1101 options.headers = {};
1102 }
1103
1104 let contentType = options.contentType || this._defaultContentType;
1105
1106 if (get(this, 'useFetch')) {
1107 if (options.data && options.type !== 'GET') {
1108 if (!options.headers['Content-Type'] && !options.headers['content-type']) {
1109 options.headers['content-type'] = contentType;
1110 }
1111 }
1112 options = fetchOptions(options, this);
1113 } else {
1114 // GET requests without a body should not have a content-type header
1115 // and may be unexpected by a server
1116 if (options.data && options.type !== 'GET') {
1117 options = assign(options, { contentType });
1118 }
1119 options = ajaxOptions(options, this);
1120 }
1121
1122 options.url = this._ajaxURL(options.url);
1123
1124 return options;
1125 },
1126
1127 _ajaxURL(url) {
1128 if (get(this, 'fastboot.isFastBoot')) {
1129 let httpRegex = /^https?:\/\//;
1130 let protocolRelativeRegex = /^\/\//;
1131 let protocol = get(this, 'fastboot.request.protocol');
1132 let host = get(this, 'fastboot.request.host');
1133
1134 if (protocolRelativeRegex.test(url)) {
1135 return `${protocol}${url}`;
1136 } else if (!httpRegex.test(url)) {
1137 try {
1138 return `${protocol}//${host}${url}`;
1139 } catch (fbError) {
1140 throw new Error(
1141 'You are using Ember Data with no host defined in your adapter. This will attempt to use the host of the FastBoot request, which is not configured for the current host of this request. Please set the hostWhitelist property for in your environment.js. FastBoot Error: ' +
1142 fbError.message
1143 );
1144 }
1145 }
1146 }
1147
1148 return url;
1149 },
1150
1151 /**
1152 @method parseErrorResponse
1153 @private
1154 @param {String} responseText
1155 @return {Object}
1156 */
1157 parseErrorResponse(responseText) {
1158 let json = responseText;
1159
1160 try {
1161 json = JSON.parse(responseText);
1162 } catch (e) {
1163 // ignored
1164 }
1165
1166 return json;
1167 },
1168
1169 /**
1170 @method normalizeErrorResponse
1171 @private
1172 @param {Number} status
1173 @param {Object} headers
1174 @param {Object} payload
1175 @return {Array} errors payload
1176 */
1177 normalizeErrorResponse(status, headers, payload) {
1178 if (payload && typeof payload === 'object' && payload.errors) {
1179 return payload.errors;
1180 } else {
1181 return [
1182 {
1183 status: `${status}`,
1184 title: 'The backend responded with an error',
1185 detail: `${payload}`,
1186 },
1187 ];
1188 }
1189 },
1190
1191 /**
1192 Generates a detailed ("friendly") error message, with plenty
1193 of information for debugging (good luck!)
1194
1195 @method generatedDetailedMessage
1196 @private
1197 @param {Number} status
1198 @param {Object} headers
1199 @param {Object} payload
1200 @param {Object} requestData
1201 @return {String} detailed error message
1202 */
1203 generatedDetailedMessage: function(status, headers, payload, requestData) {
1204 let shortenedPayload;
1205 let payloadContentType = headers['content-type'] || 'Empty Content-Type';
1206
1207 if (payloadContentType === 'text/html' && payload.length > 250) {
1208 shortenedPayload = '[Omitted Lengthy HTML]';
1209 } else {
1210 shortenedPayload = payload;
1211 }
1212
1213 let requestDescription = requestData.method + ' ' + requestData.url;
1214 let payloadDescription = 'Payload (' + payloadContentType + ')';
1215
1216 return [
1217 'Ember Data Request ' + requestDescription + ' returned a ' + status,
1218 payloadDescription,
1219 shortenedPayload,
1220 ].join('\n');
1221 },
1222
1223 // @since 2.5.0
1224 buildQuery(snapshot) {
1225 let query = {};
1226
1227 if (snapshot) {
1228 let { include } = snapshot;
1229
1230 if (include) {
1231 query.include = include;
1232 }
1233 }
1234
1235 return query;
1236 },
1237});
1238
1239function ajaxSuccess(adapter, payload, requestData, responseData) {
1240 let response;
1241 try {
1242 response = adapter.handleResponse(responseData.status, responseData.headers, payload, requestData);
1243 } catch (error) {
1244 return Promise.reject(error);
1245 }
1246
1247 if (response && response.isAdapterError) {
1248 return Promise.reject(response);
1249 } else {
1250 return response;
1251 }
1252}
1253
1254function ajaxError(adapter, payload, requestData, responseData) {
1255 let error;
1256
1257 if (responseData.errorThrown instanceof Error && payload !== '') {
1258 error = responseData.errorThrown;
1259 } else if (responseData.textStatus === 'timeout') {
1260 error = new TimeoutError();
1261 } else if (responseData.textStatus === 'abort' || responseData.status === 0) {
1262 error = handleAbort(requestData, responseData);
1263 } else {
1264 try {
1265 error = adapter.handleResponse(
1266 responseData.status,
1267 responseData.headers,
1268 payload || responseData.errorThrown,
1269 requestData
1270 );
1271 } catch (e) {
1272 error = e;
1273 }
1274 }
1275
1276 return error;
1277}
1278
1279// Adapter abort error to include any relevent info, e.g. request/response:
1280function handleAbort(requestData, responseData) {
1281 let { method, url, errorThrown } = requestData;
1282 let { status } = responseData;
1283 let msg = `Request failed: ${method} ${url} ${errorThrown || ''}`;
1284 let errors = [{ title: 'Adapter Error', detail: msg.trim(), status }];
1285 return new AbortError(errors);
1286}
1287
1288//From http://stackoverflow.com/questions/280634/endswith-in-javascript
1289function endsWith(string, suffix) {
1290 if (typeof String.prototype.endsWith !== 'function') {
1291 return string.indexOf(suffix, string.length - suffix.length) !== -1;
1292 } else {
1293 return string.endsWith(suffix);
1294 }
1295}
1296
1297function fetchSuccessHandler(adapter, payload, response, requestData) {
1298 let responseData = fetchResponseData(response);
1299 return ajaxSuccess(adapter, payload, requestData, responseData);
1300}
1301
1302function fetchErrorHandler(adapter, payload, response, errorThrown, requestData) {
1303 let responseData = fetchResponseData(response);
1304
1305 if (responseData.status === 200 && payload instanceof Error) {
1306 responseData.errorThrown = payload;
1307 payload = responseData.errorThrown.payload;
1308 } else {
1309 responseData.errorThrown = errorThrown;
1310 payload = adapter.parseErrorResponse(payload);
1311 }
1312 return ajaxError(adapter, payload, requestData, responseData);
1313}
1314
1315function ajaxSuccessHandler(adapter, payload, jqXHR, requestData) {
1316 let responseData = ajaxResponseData(jqXHR);
1317 return ajaxSuccess(adapter, payload, requestData, responseData);
1318}
1319
1320function ajaxErrorHandler(adapter, jqXHR, errorThrown, requestData) {
1321 let responseData = ajaxResponseData(jqXHR);
1322 responseData.errorThrown = errorThrown;
1323 let payload = adapter.parseErrorResponse(jqXHR.responseText);
1324
1325 if (DEBUG) {
1326 let message = `The server returned an empty string for ${requestData.method} ${requestData.url}, which cannot be parsed into a valid JSON. Return either null or {}.`;
1327 let validJSONString = !(responseData.textStatus === 'parsererror' && payload === '');
1328 warn(message, validJSONString, {
1329 id: 'ds.adapter.returned-empty-string-as-JSON',
1330 });
1331 }
1332
1333 return ajaxError(adapter, payload, requestData, responseData);
1334}
1335
1336function fetchResponseData(response) {
1337 return {
1338 status: response.status,
1339 textStatus: response.textStatus,
1340 headers: headersToObject(response.headers),
1341 };
1342}
1343
1344function ajaxResponseData(jqXHR) {
1345 return {
1346 status: jqXHR.status,
1347 textStatus: jqXHR.statusText,
1348 headers: parseResponseHeaders(jqXHR.getAllResponseHeaders()),
1349 };
1350}
1351
1352function headersToObject(headers) {
1353 let headersObject = {};
1354
1355 if (headers) {
1356 headers.forEach((value, key) => (headersObject[key] = value));
1357 }
1358
1359 return headersObject;
1360}
1361
1362/**
1363 * Helper function that translates the options passed to `jQuery.ajax` into a format that `fetch` expects.
1364 * @param {Object} _options
1365 * @param {Adapter} adapter
1366 * @returns {Object}
1367 */
1368export function fetchOptions(options, adapter) {
1369 options.credentials = 'same-origin';
1370
1371 if (options.data) {
1372 // GET and HEAD requests can't have a `body`
1373 if (options.method === 'GET' || options.method === 'HEAD') {
1374 // If no options are passed, Ember Data sets `data` to an empty object, which we test for.
1375 if (Object.keys(options.data).length) {
1376 // Test if there are already query params in the url (mimics jQuey.ajax).
1377 const queryParamDelimiter = options.url.indexOf('?') > -1 ? '&' : '?';
1378 options.url += `${queryParamDelimiter}${serializeQueryParams(options.data)}`;
1379 }
1380 } else {
1381 // NOTE: a request's body cannot be an object, so we stringify it if it is.
1382 // JSON.stringify removes keys with values of `undefined` (mimics jQuery.ajax).
1383 // If the data is not a POJO (it's a String, FormData, etc), we just set it.
1384 // If the data is a string, we assume it's a stringified object.
1385
1386 /* We check for Objects this way because we want the logic inside the consequent to run
1387 * if `options.data` is a POJO, not if it is a data structure whose `typeof` returns "object"
1388 * when it's not (Array, FormData, etc). The reason we don't use `options.data.constructor`
1389 * to check is in case `data` is an object with no prototype (e.g. created with null).
1390 */
1391 if (Object.prototype.toString.call(options.data) === '[object Object]') {
1392 options.body = JSON.stringify(options.data);
1393 } else {
1394 options.body = options.data;
1395 }
1396 }
1397 }
1398
1399 return options;
1400}
1401
1402function ajaxOptions(options, adapter) {
1403 options.dataType = 'json';
1404 options.context = adapter;
1405
1406 if (options.data && options.type !== 'GET') {
1407 options.data = JSON.stringify(options.data);
1408 }
1409
1410 options.beforeSend = function(xhr) {
1411 Object.keys(options.headers).forEach(key => xhr.setRequestHeader(key, options.headers[key]));
1412 };
1413
1414 return options;
1415}
1416
1417export default RESTAdapter;