├── app ├── .gitkeep └── services │ └── fetch.js ├── addon ├── .gitkeep ├── fetch-request.js ├── services │ └── fetch.js ├── -private │ ├── utils │ │ ├── is-string.js │ │ ├── json-helpers.js │ │ └── url-helpers.js │ └── constants │ │ └── response.js ├── request.js ├── errors.js └── mixins │ └── fetch-request.js ├── tests ├── unit │ ├── .gitkeep │ ├── services │ │ └── fetch-test.js │ ├── request-test.js │ ├── errors-test.js │ └── mixins │ │ └── fetch-request-test.js ├── integration │ └── .gitkeep ├── dummy │ ├── app │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── models │ │ │ └── .gitkeep │ │ ├── routes │ │ │ └── .gitkeep │ │ ├── components │ │ │ └── .gitkeep │ │ ├── controllers │ │ │ └── .gitkeep │ │ ├── styles │ │ │ └── app.css │ │ ├── templates │ │ │ ├── application.hbs │ │ │ ├── not-found.hbs │ │ │ ├── docs.hbs │ │ │ ├── docs │ │ │ │ ├── index.md │ │ │ │ ├── abort.md │ │ │ │ └── usage.md │ │ │ └── index.hbs │ │ ├── app.js │ │ ├── router.js │ │ └── index.html │ ├── public │ │ └── robots.txt │ └── config │ │ ├── optional-features.json │ │ ├── targets.js │ │ ├── addon-docs.js │ │ ├── ember-cli-update.json │ │ ├── deploy.js │ │ ├── environment.js │ │ └── ember-try.js ├── helpers │ ├── json.js │ └── index.js ├── test-helper.js └── index.html ├── .watchmanconfig ├── .template-lintrc.js ├── .stylelintrc.js ├── .stylelintignore ├── .prettierignore ├── .prettierrc.js ├── .eslintignore ├── .ember-cli ├── .gitignore ├── .editorconfig ├── index.js ├── testem.js ├── .npmignore ├── CONTRIBUTING.md ├── README.md ├── ember-cli-build.js ├── LICENSE.md ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── RELEASE.md ├── CHANGELOG.md └── package.json /app/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["dist"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --brand-primary: #00a85d; 3 | } 4 | -------------------------------------------------------------------------------- /app/services/fetch.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-ajax-fetch/services/fetch'; 2 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | }; 6 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{outlet}} 4 | 5 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: ['stylelint-config-standard', 'stylelint-prettier/recommended'], 5 | }; 6 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | # unconventional files 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # addons 8 | /.node_modules.ember-try/ 9 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/not-found.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Not found

3 |

This page doesn't exist. Head home?

4 |
-------------------------------------------------------------------------------- /addon/fetch-request.js: -------------------------------------------------------------------------------- 1 | import EmberObject from '@ember/object'; 2 | import FetchRequestMixin from './mixins/fetch-request'; 3 | 4 | export default EmberObject.extend(FetchRequestMixin); 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # misc 8 | /coverage/ 9 | !.* 10 | .*/ 11 | 12 | # ember-try 13 | /.node_modules.ember-try/ 14 | -------------------------------------------------------------------------------- /tests/dummy/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | overrides: [ 5 | { 6 | files: '*.{js,ts}', 7 | options: { 8 | singleQuote: true, 9 | }, 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /declarations/ 6 | /dist/ 7 | 8 | # misc 9 | /coverage/ 10 | !.* 11 | .*/ 12 | 13 | # ember-try 14 | /.node_modules.ember-try/ 15 | -------------------------------------------------------------------------------- /tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions', 7 | ]; 8 | 9 | module.exports = { 10 | browsers, 11 | }; 12 | -------------------------------------------------------------------------------- /addon/services/fetch.js: -------------------------------------------------------------------------------- 1 | import Service from '@ember/service'; 2 | import FetchRequestMixin from '../mixins/fetch-request'; 3 | 4 | /** 5 | * @class FetchService 6 | */ 7 | const FetchService = Service.extend(FetchRequestMixin); 8 | 9 | export default FetchService; 10 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript 4 | rather than JavaScript by default, when a TypeScript version of a given blueprint is available. 5 | */ 6 | "isTypeScriptProject": false 7 | } 8 | -------------------------------------------------------------------------------- /addon/-private/utils/is-string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if the passed value is a string 3 | * @param {*} object 4 | * @return {boolean} 5 | * @function isString 6 | * @private 7 | */ 8 | export default function isString(object) { 9 | return typeof object === 'string'; 10 | } 11 | -------------------------------------------------------------------------------- /addon/-private/constants/response.js: -------------------------------------------------------------------------------- 1 | export const COMPRESSED_TYPES = [ 2 | 'application/zip', 3 | 'application/x-tar', 4 | 'application/gzip', 5 | 'application/x-bzip2', 6 | 'application/x-7z-compressed', 7 | 'application/x-rar-compressed', 8 | 'application/x-xz', 9 | 'application/vnd.ms-cab-compressed', 10 | ]; 11 | -------------------------------------------------------------------------------- /tests/dummy/config/addon-docs.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const AddonDocsConfig = require('ember-cli-addon-docs/lib/config'); 5 | 6 | module.exports = class extends AddonDocsConfig { 7 | // See https://ember-learn.github.io/ember-cli-addon-docs/docs/deploying 8 | // for details on configuration you can override here. 9 | }; 10 | -------------------------------------------------------------------------------- /tests/helpers/json.js: -------------------------------------------------------------------------------- 1 | export function jsonResponse(status = 200, payload = {}) { 2 | return [ 3 | status, 4 | { 'Content-Type': 'application/json' }, 5 | JSON.stringify(payload), 6 | ]; 7 | } 8 | 9 | export function jsonFactory(status, payload) { 10 | return function json() { 11 | return jsonResponse(status, payload); 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/docs.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{nav.section "Introduction"}} 4 | {{nav.item "Introduction" "docs.index"}} 5 | {{nav.item "Usage" "docs.usage"}} 6 | {{nav.item "Aborting Requests" "docs.abort"}} 7 | 8 | 9 | 10 | {{outlet}} 11 | 12 | -------------------------------------------------------------------------------- /tests/unit/services/fetch-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | 4 | module('Unit | Service | fetch', function (hooks) { 5 | setupTest(hooks); 6 | 7 | // Replace this with your real tests. 8 | test('it exists', function (assert) { 9 | let service = this.owner.lookup('service:fetch'); 10 | assert.ok(service); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /addon/request.js: -------------------------------------------------------------------------------- 1 | import FetchRequest from './fetch-request'; 2 | 3 | /** 4 | * Helper function that allows you to use the default `ember-ajax-fetch` to make 5 | * requests without using the service. 6 | * 7 | * @class request 8 | * @public 9 | */ 10 | export default function request(url, options) { 11 | const fetch = FetchRequest.create(); 12 | 13 | return fetch.request(url, options); 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /declarations/ 4 | 5 | # dependencies 6 | /node_modules/ 7 | 8 | # misc 9 | /.env* 10 | /.pnp* 11 | /.eslintcache 12 | /coverage/ 13 | /npm-debug.log* 14 | /testem.log 15 | /yarn-error.log 16 | 17 | # ember-try 18 | /.node_modules.ember-try/ 19 | /npm-shrinkwrap.json.ember-try 20 | /package.json.ember-try 21 | /package-lock.json.ember-try 22 | /yarn.lock.ember-try 23 | 24 | # broccoli-debug 25 | /DEBUG/ 26 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from 'dummy/config/environment'; 5 | 6 | export default class App extends Application { 7 | modulePrefix = config.modulePrefix; 8 | podModulePrefix = config.podModulePrefix; 9 | Resolver = Resolver; 10 | } 11 | 12 | loadInitializers(App, config.modulePrefix); 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import AddonDocsRouter, { docsRoute } from 'ember-cli-addon-docs/router'; 2 | import config from 'dummy/config/environment'; 3 | 4 | const Router = AddonDocsRouter.extend({ 5 | location: config.locationType, 6 | rootURL: config.rootURL, 7 | }); 8 | 9 | Router.map(function () { 10 | docsRoute(this, function () { 11 | this.route('abort'); 12 | this.route('usage'); 13 | }); 14 | 15 | this.route('not-found', { path: '/*path' }); 16 | }); 17 | 18 | export default Router; 19 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import Application from 'dummy/app'; 2 | import config from 'dummy/config/environment'; 3 | import * as QUnit from 'qunit'; 4 | import { setApplication } from '@ember/test-helpers'; 5 | import { setup } from 'qunit-dom'; 6 | import { start } from 'ember-qunit'; 7 | 8 | import td from 'testdouble'; 9 | import installVerifyAssertion from 'testdouble-qunit'; 10 | 11 | installVerifyAssertion(QUnit, td); 12 | 13 | setApplication(Application.create(config.APP)); 14 | 15 | setup(QUnit.assert); 16 | 17 | start(); 18 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | name: require('./package').name, 5 | options: { 6 | autoImport: { 7 | webpack: { 8 | node: { 9 | global: true, 10 | }, 11 | }, 12 | }, 13 | }, 14 | included() { 15 | this.import( 16 | 'node_modules/abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js', 17 | { prepend: true }, 18 | ); 19 | this._super.included.apply(this, arguments); 20 | }, 21 | isDevelopingAddon() { 22 | return true; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /tests/dummy/config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "packages": [ 4 | { 5 | "name": "ember-cli", 6 | "version": "5.12.0", 7 | "blueprints": [ 8 | { 9 | "name": "addon", 10 | "outputRepo": "https://github.com/ember-cli/ember-addon-output", 11 | "codemodsSource": "ember-addon-codemods-manifest@1", 12 | "isBaseBlueprint": true, 13 | "options": [ 14 | "--no-welcome", 15 | "--pnpm" 16 | ] 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: ['Chrome'], 7 | launch_in_dev: ['Chrome'], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? '--no-sandbox' : null, 14 | '--headless', 15 | '--disable-dev-shm-usage', 16 | '--disable-software-rasterizer', 17 | '--mute-audio', 18 | '--remote-debugging-port=0', 19 | '--window-size=1440,900', 20 | ].filter(Boolean), 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /tmp/ 4 | 5 | # misc 6 | /.editorconfig 7 | /.ember-cli 8 | /.env* 9 | /.eslintcache 10 | /.eslintignore 11 | /.eslintrc.js 12 | /.git/ 13 | /.github/ 14 | /.gitignore 15 | /.prettierignore 16 | /.prettierrc.js 17 | /.stylelintignore 18 | /.stylelintrc.js 19 | /.template-lintrc.js 20 | /.travis.yml 21 | /.watchmanconfig 22 | /CONTRIBUTING.md 23 | /ember-cli-build.js 24 | /testem.js 25 | /tests/ 26 | /tsconfig.declarations.json 27 | /tsconfig.json 28 | /yarn-error.log 29 | /yarn.lock 30 | .gitkeep 31 | 32 | # ember-try 33 | /.node_modules.ember-try/ 34 | /npm-shrinkwrap.json.ember-try 35 | /package.json.ember-try 36 | /package-lock.json.ember-try 37 | /yarn.lock.ember-try 38 | 39 | /config/addon-docs.js 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | - `git clone ` 6 | - `cd ember-ajax-fetch` 7 | - `pnpm install` 8 | 9 | ## Linting 10 | 11 | - `pnpm lint` 12 | - `pnpm lint:fix` 13 | 14 | ## Running tests 15 | 16 | - `pnpm test` – Runs the test suite on the current Ember version 17 | - `pnpm test:ember --server` – Runs the test suite in "watch mode" 18 | - `pnpm test:ember-compatibility` – Runs the test suite against multiple Ember versions 19 | 20 | ## Running the dummy application 21 | 22 | - `pnpm start` 23 | - Visit the dummy application at [http://localhost:4200](http://localhost:4200). 24 | 25 | For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/). 26 | -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ember Ajax Fetch 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | 11 | 12 | 13 | 14 | {{content-for "head-footer"}} 15 | 16 | 17 | {{content-for "body"}} 18 | 19 | 20 | 21 | 22 | {{content-for "body-footer"}} 23 | 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ember-ajax-fetch 2 | 3 | This addon provides a `fetch` service that is meant to have the same API as 4 | [ember-ajax](https://github.com/ember-cli/ember-ajax). It should be a drop in replacement 5 | when it is finished, and already handles a lot of things! 6 | 7 | ## Compatibility 8 | 9 | - Ember.js v3.28 or above 10 | - Ember CLI v3.28 or above 11 | - Node.js v18 or above 12 | 13 | ## Installation 14 | 15 | ``` 16 | ember install ember-ajax-fetch 17 | ``` 18 | 19 | ## Docs 20 | 21 | --- 22 | 23 | [View the docs](https://robbiethewagner.github.io/ember-ajax-fetch/) 24 | 25 | ## Contributing 26 | 27 | See the [Contributing](CONTRIBUTING.md) guide for details. 28 | 29 | ## License 30 | 31 | This project is licensed under the [MIT License](LICENSE.md). 32 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | 5 | module.exports = function (defaults) { 6 | const app = new EmberAddon(defaults, { 7 | // Add options here 8 | autoImport: { 9 | webpack: { 10 | resolve: { 11 | fallback: { 12 | module: false, 13 | }, 14 | }, 15 | }, 16 | }, 17 | }); 18 | 19 | /* 20 | This build file specifies the options for the dummy test app of this 21 | addon, located in `/tests/dummy` 22 | This build file does *not* influence how the addon or the app using it 23 | behave. You most likely want to be modifying `./index.js` or app's build file 24 | */ 25 | 26 | const { maybeEmbroider } = require('@embroider/test-setup'); 27 | return maybeEmbroider(app, { 28 | skipBabel: [ 29 | { 30 | package: 'qunit', 31 | }, 32 | ], 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /tests/dummy/config/deploy.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function (deployTarget) { 5 | let ENV = { 6 | build: {}, 7 | // include other plugin configuration that applies to all deploy targets here 8 | }; 9 | 10 | if (deployTarget === 'development') { 11 | ENV.build.environment = 'development'; 12 | // configure other plugins for development deploy target here 13 | } 14 | 15 | if (deployTarget === 'staging') { 16 | ENV.build.environment = 'production'; 17 | // configure other plugins for staging deploy target here 18 | } 19 | 20 | if (deployTarget === 'production') { 21 | ENV.build.environment = 'production'; 22 | // configure other plugins for production deploy target here 23 | } 24 | 25 | // Note: if you need to build some configuration asynchronously, you can return 26 | // a promise that resolves with the ENV object instead of returning the 27 | // ENV object synchronously. 28 | return ENV; 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/docs/index.md: -------------------------------------------------------------------------------- 1 | ember-ajax-fetch 2 | ============================================================================== 3 | 4 | This addon provides a `fetch` service that is meant to have the same API as 5 | [ember-ajax](https://github.com/ember-cli/ember-ajax). It should be a drop in replacement 6 | when it is finished, and already handles a lot of things! 7 | 8 | 9 | Compatibility 10 | ------------------------------------------------------------------------------ 11 | 12 | * Ember.js v3.4 or above 13 | * Ember CLI v2.13 or above 14 | * Node.js v8 or above 15 | 16 | 17 | Installation 18 | ------------------------------------------------------------------------------ 19 | 20 | ``` 21 | ember install ember-ajax-fetch 22 | ``` 23 | 24 | 25 | Contributing 26 | ------------------------------------------------------------------------------ 27 | 28 | See the [Contributing](CONTRIBUTING.md) guide for details. 29 | 30 | 31 | License 32 | ------------------------------------------------------------------------------ 33 | 34 | This project is licensed under the [MIT License](LICENSE.md). 35 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/docs/abort.md: -------------------------------------------------------------------------------- 1 | # Aborting Requests 2 | 3 | Fetch requests can be aborted by utilizing an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). 4 | They are not supported in IE11, but we have included a [polyfill](https://www.npmjs.com/package/abortcontroller-polyfill) for IE11, 5 | so it should catch the error. 6 | 7 | ## AbortController Example 8 | 9 | You will want to create a new `AbortController` and pass it in the options to the `fetch` request. 10 | 11 | ```js 12 | import Route from '@ember/routing/route'; 13 | import { inject as service } from '@ember/service'; 14 | 15 | export default class FooRoute extends Route { 16 | @service fetch; 17 | 18 | const queryParams = { 19 | 'filter[interval_type]': 'month' 20 | }; 21 | 22 | model() { 23 | const abortController = new AbortController(); 24 | return this.fetch.request('/foo/bar', { 25 | data: queryParams, 26 | signal: abortController.signal 27 | }); 28 | } 29 | } 30 | ``` 31 | 32 | You can then cancel a request later by using the `AbortController` you created. 33 | 34 | ```js 35 | abortController.abort(); 36 | ``` 37 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dummy Tests 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | {{content-for "test-head"}} 11 | 12 | 13 | 14 | 15 | 16 | {{content-for "head-footer"}} 17 | {{content-for "test-head-footer"}} 18 | 19 | 20 | {{content-for "body"}} 21 | {{content-for "test-body"}} 22 | 23 |
24 |
25 |
26 |
27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {{content-for "body-footer"}} 37 | {{content-for "test-body-footer"}} 38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/helpers/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | setupApplicationTest as upstreamSetupApplicationTest, 3 | setupRenderingTest as upstreamSetupRenderingTest, 4 | setupTest as upstreamSetupTest, 5 | } from 'ember-qunit'; 6 | 7 | // This file exists to provide wrappers around ember-qunit's 8 | // test setup functions. This way, you can easily extend the setup that is 9 | // needed per test type. 10 | 11 | function setupApplicationTest(hooks, options) { 12 | upstreamSetupApplicationTest(hooks, options); 13 | 14 | // Additional setup for application tests can be done here. 15 | // 16 | // For example, if you need an authenticated session for each 17 | // application test, you could do: 18 | // 19 | // hooks.beforeEach(async function () { 20 | // await authenticateSession(); // ember-simple-auth 21 | // }); 22 | // 23 | // This is also a good place to call test setup functions coming 24 | // from other addons: 25 | // 26 | // setupIntl(hooks, 'en-us'); // ember-intl 27 | // setupMirage(hooks); // ember-cli-mirage 28 | } 29 | 30 | function setupRenderingTest(hooks, options) { 31 | upstreamSetupRenderingTest(hooks, options); 32 | 33 | // Additional setup for rendering tests can be done here. 34 | } 35 | 36 | function setupTest(hooks, options) { 37 | upstreamSetupTest(hooks, options); 38 | 39 | // Additional setup for unit tests can be done here. 40 | } 41 | 42 | export { setupApplicationTest, setupRenderingTest, setupTest }; 43 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (environment) { 4 | const ENV = { 5 | modulePrefix: 'dummy', 6 | environment, 7 | rootURL: '/', 8 | locationType: 'history', 9 | EmberENV: { 10 | EXTEND_PROTOTYPES: false, 11 | FEATURES: { 12 | // Here you can enable experimental features on an ember canary build 13 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 14 | }, 15 | }, 16 | 17 | APP: { 18 | // Here you can pass flags/options to your application instance 19 | // when it is created 20 | }, 21 | }; 22 | 23 | if (environment === 'development') { 24 | // ENV.APP.LOG_RESOLVER = true; 25 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 26 | // ENV.APP.LOG_TRANSITIONS = true; 27 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 28 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 29 | } 30 | 31 | if (environment === 'test') { 32 | // Testem prefers this... 33 | ENV.locationType = 'none'; 34 | 35 | // keep test console output quieter 36 | ENV.APP.LOG_ACTIVE_GENERATION = false; 37 | ENV.APP.LOG_VIEW_LOOKUPS = false; 38 | 39 | ENV.APP.rootElement = '#ember-testing'; 40 | ENV.APP.autoboot = false; 41 | } 42 | 43 | if (environment === 'production') { 44 | // Allow ember-cli-addon-docs to update the rootURL in compiled assets 45 | ENV.rootURL = 'ADDON_DOCS_ROOT_URL'; 46 | // here you can enable a production-specific feature 47 | } 48 | 49 | return ENV; 50 | }; 51 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/index.hbs: -------------------------------------------------------------------------------- 1 |
4 |
5 |
6 |
7 |
8 |
9 |

10 | 11 | {{svg-jar "ember" class="docs-h-full docs-w-auto docs-max-w-full docs-fill-current" height="auto" width="125px"}} 12 | 13 | 14 | AJAX Fetch 15 |

16 | 17 |

19 | A drop-in ember-ajax replacement fetch service. 20 |

21 | 22 | 27 | Read the docs → 28 | 29 |
30 |
31 |
32 |
33 |
34 |
-------------------------------------------------------------------------------- /tests/unit/request-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | import { isNotFoundResponse } from 'ember-fetch/errors'; 4 | import Pretender from 'pretender'; 5 | import request from 'ember-ajax-fetch/request'; 6 | 7 | module('Unit | request', function (hooks) { 8 | setupTest(hooks); 9 | 10 | hooks.beforeEach(function () { 11 | this.server = new Pretender(); 12 | }); 13 | 14 | hooks.afterEach(function () { 15 | this.server.shutdown(); 16 | }); 17 | 18 | test('produces data', function (assert) { 19 | const photos = [ 20 | { id: 10, src: 'http://media.giphy.com/media/UdqUo8xvEcvgA/giphy.gif' }, 21 | { id: 42, src: 'http://media0.giphy.com/media/Ko2pyD26RdYRi/giphy.gif' }, 22 | ]; 23 | this.server.get('/photos', function () { 24 | return [ 25 | 200, 26 | { 'Content-Type': 'application/json' }, 27 | JSON.stringify(photos), 28 | ]; 29 | }); 30 | return request('/photos').then(function (data) { 31 | assert.deepEqual(data, photos); 32 | }); 33 | }); 34 | 35 | test('rejects promise when 404 is returned', function (assert) { 36 | this.server.get('/photos', function () { 37 | return [404, { 'Content-Type': 'application/json' }]; 38 | }); 39 | 40 | let errorCalled; 41 | return request('/photos') 42 | .then(function () { 43 | errorCalled = false; 44 | }) 45 | .catch(function (response) { 46 | assert.ok(isNotFoundResponse(response)); 47 | errorCalled = true; 48 | }) 49 | .finally(function () { 50 | assert.ok(errorCalled); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | parser: '@babel/eslint-parser', 6 | parserOptions: { 7 | ecmaVersion: 'latest', 8 | sourceType: 'module', 9 | requireConfigFile: false, 10 | babelOptions: { 11 | plugins: [ 12 | ['@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true }], 13 | ], 14 | }, 15 | }, 16 | plugins: ['ember'], 17 | extends: [ 18 | 'eslint:recommended', 19 | 'plugin:ember/recommended', 20 | 'plugin:prettier/recommended', 21 | ], 22 | env: { 23 | browser: true, 24 | }, 25 | globals: { 26 | FastBoot: false, 27 | }, 28 | rules: { 29 | 'ember/no-get': 'off', 30 | 'ember/no-jquery': 'error', 31 | 'ember/no-mixins': 'off', 32 | 'ember/no-new-mixins': 'off', 33 | 'no-console': 'off', 34 | 'no-useless-catch': 'off', 35 | }, 36 | overrides: [ 37 | // node files 38 | { 39 | files: [ 40 | './.eslintrc.js', 41 | './.prettierrc.js', 42 | './.stylelintrc.js', 43 | './.template-lintrc.js', 44 | './ember-cli-build.js', 45 | './index.js', 46 | './testem.js', 47 | './blueprints/*/index.js', 48 | './config/**/*.js', 49 | './tests/dummy/config/**/*.js', 50 | ], 51 | parserOptions: { 52 | sourceType: 'script', 53 | }, 54 | env: { 55 | browser: false, 56 | node: true, 57 | }, 58 | extends: ['plugin:n/recommended'], 59 | }, 60 | { 61 | // test files 62 | files: ['tests/**/*-test.{js,ts}'], 63 | extends: ['plugin:qunit/recommended'], 64 | rules: { 65 | 'qunit/no-assert-logical-expression': 'off', 66 | 'qunit/no-commented-tests': 'off', 67 | 'qunit/require-expect': 'off', 68 | }, 69 | }, 70 | ], 71 | }; 72 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: {} 9 | 10 | concurrency: 11 | group: ci-${{ github.head_ref || github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | name: "Tests" 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 10 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v3 23 | with: 24 | version: 9 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: 18 28 | cache: pnpm 29 | - name: Install Dependencies 30 | run: pnpm install --frozen-lockfile 31 | - name: Lint 32 | run: pnpm lint 33 | - name: Run Tests 34 | run: pnpm test 35 | 36 | floating: 37 | name: "Floating Dependencies" 38 | runs-on: ubuntu-latest 39 | timeout-minutes: 10 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: pnpm/action-setup@v3 44 | with: 45 | version: 9 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: 18 49 | cache: pnpm 50 | - name: Install Dependencies 51 | run: pnpm install --no-lockfile 52 | - name: Run Tests 53 | run: pnpm test 54 | 55 | try-scenarios: 56 | name: ${{ matrix.try-scenario }} 57 | runs-on: ubuntu-latest 58 | needs: "test" 59 | timeout-minutes: 10 60 | 61 | strategy: 62 | fail-fast: false 63 | matrix: 64 | try-scenario: 65 | - ember-lts-3.28 66 | - ember-lts-4.4 67 | - ember-lts-4.8 68 | - ember-lts-4.12 69 | - ember-lts-5.4 70 | - ember-release 71 | - ember-beta 72 | - ember-canary 73 | - embroider-safe 74 | - embroider-optimized 75 | 76 | steps: 77 | - uses: actions/checkout@v4 78 | - uses: pnpm/action-setup@v3 79 | with: 80 | version: 9 81 | - uses: actions/setup-node@v4 82 | with: 83 | node-version: 18 84 | cache: pnpm 85 | - name: Install Dependencies 86 | run: pnpm install --frozen-lockfile 87 | - name: Run Tests 88 | run: ./node_modules/.bin/ember try:one ${{ matrix.try-scenario }} 89 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | Releases are mostly automated using 4 | [release-it](https://github.com/release-it/release-it/) and 5 | [lerna-changelog](https://github.com/lerna/lerna-changelog/). 6 | 7 | 8 | ## Preparation 9 | 10 | Since the majority of the actual release process is automated, the primary 11 | remaining task prior to releasing is confirming that all pull requests that 12 | have been merged since the last release have been labeled with the appropriate 13 | `lerna-changelog` labels and the titles have been updated to ensure they 14 | represent something that would make sense to our users. Some great information 15 | on why this is important can be found at 16 | [keepachangelog.com](https://keepachangelog.com/en/1.0.0/), but the overall 17 | guiding principle here is that changelogs are for humans, not machines. 18 | 19 | When reviewing merged PR's the labels to be used are: 20 | 21 | * breaking - Used when the PR is considered a breaking change. 22 | * enhancement - Used when the PR adds a new feature or enhancement. 23 | * bug - Used when the PR fixes a bug included in a previous release. 24 | * documentation - Used when the PR adds or updates documentation. 25 | * internal - Used for internal changes that still require a mention in the 26 | changelog/release notes. 27 | 28 | 29 | ## Release 30 | 31 | Once the prep work is completed, the actual release is straight forward: 32 | 33 | * First, ensure that you have installed your projects dependencies: 34 | 35 | ``` 36 | npm install 37 | ``` 38 | 39 | * Second, ensure that you have obtained a 40 | [GitHub personal access token][generate-token] with the `repo` scope (no 41 | other permissions are needed). Make sure the token is available as the 42 | `GITHUB_AUTH` environment variable. 43 | 44 | For instance: 45 | 46 | ```bash 47 | export GITHUB_AUTH=abc123def456 48 | ``` 49 | 50 | [generate-token]: https://github.com/settings/tokens/new?scopes=repo&description=GITHUB_AUTH+env+variable 51 | 52 | * And last (but not least 😁) do your release. 53 | 54 | ``` 55 | npx release-it 56 | ``` 57 | 58 | [release-it](https://github.com/release-it/release-it/) manages the actual 59 | release process. It will prompt you to to choose the version number after which 60 | you will have the chance to hand tweak the changelog to be used (for the 61 | `CHANGELOG.md` and GitHub release), then `release-it` continues on to tagging, 62 | pushing the tag and commits, etc. 63 | -------------------------------------------------------------------------------- /addon/errors.js: -------------------------------------------------------------------------------- 1 | export class FetchError extends Error { 2 | constructor(payload, message = 'Ajax operation failed', status) { 3 | super(message); 4 | 5 | this.payload = payload; 6 | this.status = status; 7 | } 8 | } 9 | 10 | export class InvalidError extends FetchError { 11 | constructor(payload) { 12 | super(payload, 'Request was rejected because it was invalid', 422); 13 | } 14 | } 15 | 16 | export class UnauthorizedError extends FetchError { 17 | constructor(payload) { 18 | super(payload, 'Ajax authorization failed', 401); 19 | } 20 | } 21 | 22 | export class ForbiddenError extends FetchError { 23 | constructor(payload) { 24 | super( 25 | payload, 26 | 'Request was rejected because user is not permitted to perform this operation.', 27 | 403, 28 | ); 29 | } 30 | } 31 | 32 | export class BadRequestError extends FetchError { 33 | constructor(payload) { 34 | super(payload, 'Request was formatted incorrectly.', 400); 35 | } 36 | } 37 | 38 | export class NotFoundError extends FetchError { 39 | constructor(payload) { 40 | super(payload, 'Resource was not found.', 404); 41 | } 42 | } 43 | 44 | export class GoneError extends FetchError { 45 | constructor(payload) { 46 | super(payload, 'Resource is no longer available.', 410); 47 | } 48 | } 49 | 50 | export class TimeoutError extends FetchError { 51 | constructor() { 52 | super(null, 'The ajax operation timed out', -1); 53 | } 54 | } 55 | 56 | export class AbortError extends FetchError { 57 | constructor() { 58 | super(null, 'The ajax operation was aborted', 0); 59 | 60 | this.name = 'AbortError'; 61 | } 62 | } 63 | 64 | export class ConflictError extends FetchError { 65 | constructor(payload) { 66 | super(payload, 'The ajax operation failed due to a conflict', 409); 67 | } 68 | } 69 | 70 | export class ServerError extends FetchError { 71 | constructor(payload, status) { 72 | super(payload, 'Request was rejected due to server error', status); 73 | } 74 | } 75 | 76 | /** 77 | * Checks if the given error is or inherits from FetchError 78 | * @function isFetchError 79 | */ 80 | export function isFetchError(error) { 81 | return error instanceof FetchError; 82 | } 83 | 84 | /** 85 | * Checks if the given object represents a "timeout" error 86 | * @function isTimeoutError 87 | */ 88 | export function isTimeoutError(error) { 89 | return error instanceof TimeoutError; 90 | } 91 | -------------------------------------------------------------------------------- /tests/dummy/config/ember-try.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getChannelURL = require('ember-source-channel-url'); 4 | const { embroiderSafe, embroiderOptimized } = require('@embroider/test-setup'); 5 | 6 | module.exports = async function () { 7 | return { 8 | usePnpm: true, 9 | buildManagerOptions() { 10 | return ['--ignore-scripts', '--no-frozen-lockfile']; 11 | }, 12 | scenarios: [ 13 | { 14 | name: 'ember-lts-3.28', 15 | npm: { 16 | devDependencies: { 17 | '@ember/test-helpers': '^2.5.0', 18 | 'ember-cli': '^3.28.0', 19 | 'ember-data': '^3.28.0', 20 | 'ember-qunit': '^6.0.0', 21 | 'ember-resolver': '^8.0.0', 22 | 'ember-source': '~3.28.6', 23 | }, 24 | }, 25 | }, 26 | { 27 | name: 'ember-lts-4.4', 28 | npm: { 29 | devDependencies: { 30 | 'ember-resolver': '^11.0.1', 31 | 'ember-source': '~4.4.0', 32 | }, 33 | }, 34 | }, 35 | { 36 | name: 'ember-lts-4.8', 37 | npm: { 38 | devDependencies: { 39 | 'ember-resolver': '^11.0.1', 40 | 'ember-source': '~4.8.0', 41 | }, 42 | }, 43 | }, 44 | { 45 | name: 'ember-lts-4.12', 46 | npm: { 47 | devDependencies: { 48 | 'ember-source': '~4.12.0', 49 | }, 50 | }, 51 | }, 52 | { 53 | name: 'ember-lts-5.4', 54 | npm: { 55 | devDependencies: { 56 | 'ember-source': '~5.4.0', 57 | }, 58 | }, 59 | }, 60 | { 61 | name: 'ember-release', 62 | npm: { 63 | devDependencies: { 64 | 'ember-source': await getChannelURL('release'), 65 | }, 66 | }, 67 | }, 68 | { 69 | name: 'ember-beta', 70 | npm: { 71 | devDependencies: { 72 | 'ember-cli': '~3.28.0', 73 | 'ember-source': await getChannelURL('beta'), 74 | }, 75 | }, 76 | }, 77 | { 78 | name: 'ember-canary', 79 | npm: { 80 | devDependencies: { 81 | 'ember-cli': '~3.28.0', 82 | 'ember-source': await getChannelURL('canary'), 83 | }, 84 | }, 85 | }, 86 | embroiderSafe(), 87 | embroiderOptimized(), 88 | ], 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ## v2.1.0 (2025-03-26) 5 | 6 | #### :rocket: Enhancement 7 | * [#63](https://github.com/RobbieTheWagner/ember-ajax-fetch/pull/63) Add support for compressed types response ([@batrasaurabh90](https://github.com/batrasaurabh90)) 8 | 9 | #### Committers: 1 10 | - Saurabh Batra ([@batrasaurabh90](https://github.com/batrasaurabh90)) 11 | 12 | ## v2.0.1 (2025-02-19) 13 | 14 | #### :bug: Bug Fix 15 | * [#60](https://github.com/RobbieTheWagner/ember-ajax-fetch/pull/60) Return text response ([@damatri](https://github.com/damatri)) 16 | 17 | #### Committers: 1 18 | - [@damatri](https://github.com/damatri) 19 | 20 | ## v2.0.0 (2025-02-18) 21 | 22 | #### :boom: Breaking Change 23 | * [#62](https://github.com/RobbieTheWagner/ember-ajax-fetch/pull/62) Require Ember 3.28+, node >= 18 ([@RobbieTheWagner](https://github.com/RobbieTheWagner)) 24 | 25 | #### :rocket: Enhancement 26 | * [#59](https://github.com/RobbieTheWagner/ember-ajax-fetch/pull/59) Remove deprecated `EmberError` and `assign` ([@wozny1989](https://github.com/wozny1989)) 27 | 28 | #### Committers: 8 29 | - AJ McClure ([@audiocipher](https://github.com/audiocipher)) 30 | - Adam Woźny ([@wozny1989](https://github.com/wozny1989)) 31 | - Andrew Regan ([@andrew-regan-expel](https://github.com/andrew-regan-expel)) 32 | - Camille TJHOA ([@ctjhoa](https://github.com/ctjhoa)) 33 | - Daren McCulley ([@mcculleydj](https://github.com/mcculleydj)) 34 | - Matthew Blasius ([@slickmb](https://github.com/slickmb)) 35 | - Nabeel Zafar ([@nabeelz7](https://github.com/nabeelz7)) 36 | - Robbie Wagner ([@RobbieTheWagner](https://github.com/RobbieTheWagner)) 37 | 38 | ## v1.0.3 (2021-05-20) 39 | 40 | #### :house: Internal 41 | * [#22](https://github.com/expel-io/ember-ajax-fetch/pull/22) Use GitHub actions for CI ([@rwwagner90](https://github.com/rwwagner90)) 42 | 43 | #### Committers: 2 44 | - Robert Wagner ([@rwwagner90](https://github.com/rwwagner90)) 45 | - Scott Fisk ([@scottfisk](https://github.com/scottfisk)) 46 | 47 | ## v1.0.2 (2020-12-04) 48 | 49 | 50 | ## v1.0.1 (2020-10-27) 51 | 52 | #### :house: Internal 53 | * [#19](https://github.com/expel-io/ember-ajax-fetch/pull/19) Fix security issues, update ember-fetch and move to deps ([@rwwagner90](https://github.com/rwwagner90)) 54 | * [#18](https://github.com/expel-io/ember-ajax-fetch/pull/18) Convert to yarn ([@rwwagner90](https://github.com/rwwagner90)) 55 | 56 | #### Committers: 2 57 | - Robert Wagner ([@rwwagner90](https://github.com/rwwagner90)) 58 | - Roger Studner ([@rstudner](https://github.com/rstudner)) 59 | 60 | 61 | ## v1.0.0 (2020-10-23) 62 | 63 | #### :house: Internal 64 | * [#14](https://github.com/expel-io/ember-ajax-fetch/pull/14) Add rwjblue release-it ([@rwwagner90](https://github.com/rwwagner90)) 65 | 66 | #### Committers: 1 67 | - Robert Wagner ([@rwwagner90](https://github.com/rwwagner90)) 68 | 69 | -------------------------------------------------------------------------------- /addon/-private/utils/json-helpers.js: -------------------------------------------------------------------------------- 1 | import { COMPRESSED_TYPES } from '../constants/response'; 2 | 3 | /** 4 | * Determine if a string is JSON or not 5 | * @param {string} str The string to check for JSON formatting 6 | * @return {boolean} 7 | * @function isJsonString 8 | * @private 9 | */ 10 | export function isJsonString(str) { 11 | try { 12 | const json = JSON.parse(str); 13 | return typeof json === 'object'; 14 | } catch (e) { 15 | return false; 16 | } 17 | } 18 | 19 | /** 20 | * Parses the JSON returned by a network request 21 | * 22 | * @param {object} response A response from a network request 23 | * @return {object} The parsed JSON, status from the response 24 | * @function parseJSON 25 | * @private 26 | */ 27 | export async function parseJSON(response) { 28 | const responseType = 29 | response.headers.get('content-type') || 'Empty Content-Type'; 30 | let error = { 31 | status: response.status, 32 | statusText: response.statusText, 33 | }; 34 | 35 | if (!response.ok) { 36 | const errorBody = await response.text(); 37 | error.message = errorBody; 38 | } 39 | 40 | return new Promise((resolve) => { 41 | if (responseType.includes('json')) { 42 | return response 43 | .json() 44 | .then((json) => { 45 | if (response.ok) { 46 | return resolve({ 47 | status: response.status, 48 | ok: response.ok, 49 | json, 50 | }); 51 | } else { 52 | error = Object.assign({}, json, error); 53 | 54 | return resolve(error); 55 | } 56 | }) 57 | .catch((err) => { 58 | if (isJsonString(error.message)) { 59 | error.payload = JSON.parse(error.message); 60 | } else { 61 | error.payload = error.message || err.toString(); 62 | } 63 | 64 | error.message = error.message || err.toString(); 65 | 66 | return resolve(error); 67 | }); 68 | } else if (COMPRESSED_TYPES.includes(responseType)) { 69 | return response 70 | .blob() 71 | .then((blob) => { 72 | return resolve({ 73 | status: response.status, 74 | ok: response.ok, 75 | blob, 76 | }); 77 | }) 78 | .catch((err) => { 79 | handleError(error, err); 80 | 81 | return resolve(error); 82 | }); 83 | } else { 84 | return response 85 | .text() 86 | .then((text) => { 87 | return resolve({ 88 | status: response.status, 89 | ok: response.ok, 90 | text, 91 | }); 92 | }) 93 | .catch((err) => { 94 | handleError(error, err); 95 | 96 | return resolve(error); 97 | }); 98 | } 99 | }); 100 | } 101 | 102 | // Helper function to handle JSON parsing errors 103 | function handleError(error, err) { 104 | if (isJsonString(error.message)) { 105 | error.payload = JSON.parse(error.message); 106 | } else { 107 | error.payload = error.message || err.toString(); 108 | } 109 | error.message = error.message || err.toString(); 110 | } 111 | -------------------------------------------------------------------------------- /addon/-private/utils/url-helpers.js: -------------------------------------------------------------------------------- 1 | const completeUrlRegex = /^(http|https)/; 2 | 3 | /** 4 | * Parse a URL string into an object that defines its structure 5 | * 6 | * The returned object will have the following properties: 7 | * 8 | * href: the full URL 9 | * protocol: the request protocol 10 | * hostname: the target for the request 11 | * port: the port for the request 12 | * pathname: any URL after the host 13 | * search: query parameters 14 | * hash: the URL hash 15 | * 16 | * @param {string} str 17 | * @function parseURL 18 | * @private 19 | */ 20 | export function parseURL(str) { 21 | let fullObject; 22 | 23 | if (typeof FastBoot === 'undefined') { 24 | const element = document.createElement('a'); 25 | element.href = str; 26 | fullObject = element; 27 | } else { 28 | fullObject = FastBoot.require('url').parse(str); 29 | } 30 | 31 | const desiredProps = { 32 | href: fullObject.href, 33 | protocol: fullObject.protocol, 34 | hostname: fullObject.hostname, 35 | port: fullObject.port, 36 | pathname: fullObject.pathname, 37 | search: fullObject.search, 38 | hash: fullObject.hash, 39 | }; 40 | 41 | return desiredProps; 42 | } 43 | 44 | /** 45 | * Returns true if both `a` and `b` have the same protocol, hostname, and port. 46 | * @param {string} a 47 | * @param {string} b 48 | * @return {boolean} 49 | * @function haveSameHost 50 | * @private 51 | */ 52 | export function haveSameHost(a, b) { 53 | const urlA = parseURL(a); 54 | const urlB = parseURL(b); 55 | 56 | return ( 57 | urlA.protocol === urlB.protocol && 58 | urlA.hostname === urlB.hostname && 59 | urlA.port === urlB.port 60 | ); 61 | } 62 | 63 | /** 64 | * Checks if the URL is already a full URL 65 | * @param {string} url 66 | * @return {boolean} 67 | * @function isFullURL 68 | * @private 69 | */ 70 | export function isFullURL(url) { 71 | return !!url.match(completeUrlRegex); 72 | } 73 | 74 | /** 75 | * Checks if the given string starts with '/' 76 | * @param {string} string The string to check 77 | * @return {boolean} 78 | * @function startsWithSlash 79 | * @private 80 | */ 81 | export function startsWithSlash(string) { 82 | return string.charAt(0) === '/'; 83 | } 84 | 85 | /** 86 | * Checks if the given string ends with '/' 87 | * @param {string} string The string to check 88 | * @return {boolean} 89 | * @function endsWithSlash 90 | * @private 91 | */ 92 | export function endsWithSlash(string) { 93 | return string.charAt(string.length - 1) === '/'; 94 | } 95 | 96 | /** 97 | * Remove a leading slash from the given string 98 | * @param {string} string The string to remove the slash from 99 | * @return {string} 100 | * @function removeLeadingSlash 101 | * @private 102 | */ 103 | export function removeLeadingSlash(string) { 104 | return string.substring(1); 105 | } 106 | 107 | /** 108 | * Remove a trailing slash from the given string 109 | * @param {string} string The string to remove the slash from 110 | * @return {string} 111 | * @function removeTrailingSlash 112 | * @private 113 | */ 114 | export function removeTrailingSlash(string) { 115 | return string.slice(0, -1); 116 | } 117 | 118 | /** 119 | * Strip slashes from the given path 120 | * @param {string} path The path to remove slashes from 121 | * @return {string} 122 | * @function stripSlashes 123 | * @private 124 | */ 125 | export function stripSlashes(path) { 126 | // make sure path starts with `/` 127 | if (startsWithSlash(path)) { 128 | path = removeLeadingSlash(path); 129 | } 130 | 131 | // remove end `/` 132 | if (endsWithSlash(path)) { 133 | path = removeTrailingSlash(path); 134 | } 135 | return path; 136 | } 137 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-ajax-fetch", 3 | "version": "2.1.0", 4 | "description": "A drop-in ember-ajax replacement fetch service.", 5 | "keywords": [ 6 | "ember-addon" 7 | ], 8 | "homepage": "https://expel-io.github.io/ember-ajax-fetch", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/expel-io/ember-ajax-fetch.git" 12 | }, 13 | "license": "MIT", 14 | "author": "", 15 | "directories": { 16 | "doc": "doc", 17 | "test": "tests" 18 | }, 19 | "scripts": { 20 | "build": "ember build --environment=production", 21 | "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"", 22 | "lint:css": "stylelint \"**/*.css\"", 23 | "lint:css:fix": "concurrently \"pnpm:lint:css -- --fix\"", 24 | "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\"", 25 | "lint:hbs": "ember-template-lint .", 26 | "lint:hbs:fix": "ember-template-lint . --fix", 27 | "lint:js": "eslint . --cache", 28 | "lint:js:fix": "eslint . --fix", 29 | "start": "ember serve", 30 | "test": "concurrently \"pnpm:lint\" \"pnpm:test:*\" --names \"lint,test:\"", 31 | "test:ember": "ember test" 32 | }, 33 | "dependencies": { 34 | "@babel/core": "^7.25.2", 35 | "abortcontroller-polyfill": "^1.7.8", 36 | "ember-auto-import": "^2.10.0", 37 | "ember-cli-babel": "^8.2.0", 38 | "ember-cli-htmlbars": "^6.3.0", 39 | "ember-fetch": "^8.1.2", 40 | "jquery-param": "^1.2.4" 41 | }, 42 | "devDependencies": { 43 | "@babel/eslint-parser": "^7.26.8", 44 | "@babel/plugin-proposal-decorators": "^7.25.9", 45 | "@ember/optional-features": "^2.2.0", 46 | "@ember/string": "^3.0.1", 47 | "@ember/test-helpers": "^3.3.1", 48 | "@embroider/test-setup": "^4.0.0", 49 | "@glimmer/component": "^1.1.2", 50 | "@glimmer/tracking": "^1.1.2", 51 | "@release-it-plugins/lerna-changelog": "^6.1.0", 52 | "base-64": "^1.0.0", 53 | "broccoli-asset-rev": "^3.0.0", 54 | "concurrently": "^8.2.2", 55 | "ember-cli": "~5.12.0", 56 | "ember-cli-addon-docs": "^4.2.2", 57 | "ember-cli-addon-docs-yuidoc": "^1.1.0", 58 | "ember-cli-clean-css": "^3.0.0", 59 | "ember-cli-dependency-checker": "^3.3.3", 60 | "ember-cli-deploy": "^1.0.2", 61 | "ember-cli-deploy-build": "^2.0.0", 62 | "ember-cli-deploy-git": "^1.3.4", 63 | "ember-cli-deploy-git-ci": "^1.0.1", 64 | "ember-cli-inject-live-reload": "^2.1.0", 65 | "ember-cli-sri": "^2.1.1", 66 | "ember-cli-terser": "^4.0.2", 67 | "ember-data": "^4.12.8", 68 | "ember-load-initializers": "^2.1.2", 69 | "ember-page-title": "^8.2.3", 70 | "ember-qunit": "^8.1.0", 71 | "ember-resolver": "^12.0.1", 72 | "ember-source": "^5.12.0", 73 | "ember-source-channel-url": "^3.0.0", 74 | "ember-template-lint": "^6.0.0", 75 | "ember-try": "^3.0.0", 76 | "eslint": "^8.57.1", 77 | "eslint-config-prettier": "^9.1.0", 78 | "eslint-plugin-ember": "^12.2.1", 79 | "eslint-plugin-n": "^16.6.2", 80 | "eslint-plugin-prettier": "^5.2.1", 81 | "eslint-plugin-qunit": "^8.1.2", 82 | "loader.js": "^4.7.0", 83 | "pretender": "^3.4.7", 84 | "prettier": "^3.3.3", 85 | "qunit": "^2.24.1", 86 | "qunit-dom": "^3.2.1", 87 | "release-it": "^17.11.0", 88 | "stylelint": "^15.11.0", 89 | "stylelint-config-standard": "^34.0.0", 90 | "stylelint-prettier": "^4.1.0", 91 | "testdouble": "^3.20.2", 92 | "testdouble-qunit": "^2.1.1", 93 | "webpack": "^5.98.0" 94 | }, 95 | "peerDependencies": { 96 | "ember-source": "^3.28.0 || >= 4.0.0" 97 | }, 98 | "engines": { 99 | "node": ">= 18" 100 | }, 101 | "publishConfig": { 102 | "registry": "https://registry.npmjs.org" 103 | }, 104 | "ember": { 105 | "edition": "octane" 106 | }, 107 | "ember-addon": { 108 | "configPath": "tests/dummy/config" 109 | }, 110 | "release-it": { 111 | "plugins": { 112 | "@release-it-plugins/lerna-changelog": { 113 | "infile": "CHANGELOG.md", 114 | "launchEditor": true 115 | } 116 | }, 117 | "git": { 118 | "tagName": "v${version}" 119 | }, 120 | "github": { 121 | "release": true, 122 | "tokenRef": "GITHUB_AUTH" 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Basic Usage 4 | 5 | In general, you will use the `request(url, options)` method, where url is the destination of the request and options is a 6 | configuration hash compatible with `ember-ajax`, which will be transformed to use with `fetch`. 7 | 8 | ```js 9 | import Route from '@ember/routing/route'; 10 | import { inject as service } from '@ember/service'; 11 | 12 | export default class FooRoute extends Route { 13 | @service fetch; 14 | 15 | const queryParams = { 16 | 'filter[interval_type]': 'month' 17 | }; 18 | 19 | model() { 20 | return this.fetch.request('/foo/bar', { 21 | data: queryParams 22 | }); 23 | } 24 | } 25 | ``` 26 | 27 | ### HTTP-verbed methods 28 | 29 | You can skip setting the `method` or `type` keys in your `options` object when 30 | calling `request(url, options)` by instead calling `post(url, options)`, 31 | `put(url, options)`, `patch(url, options)` or `del(url, options)`. 32 | 33 | ```js 34 | post('/posts', { data: { title: 'Ember' } }); // Makes a POST request to /posts 35 | put('/posts/1', { data: { title: 'Ember' } }); // Makes a PUT request to /posts/1 36 | patch('/posts/1', { data: { title: 'Ember' } }); // Makes a PATCH request to /posts/1 37 | del('/posts/1'); // Makes a DELETE request to /posts/1 38 | ``` 39 | 40 | ### Custom Request Headers 41 | 42 | `ember-ajax-fetch` allows you to specify headers to be used with a request. This is 43 | especially helpful when you have a session service that provides an auth token 44 | that you have to include with the requests to authorize your requests. 45 | 46 | To include custom headers to be used with your requests, you can specify 47 | `headers` hash on the `Fetch Service`. 48 | 49 | ```js 50 | // app/services/fetch.js 51 | 52 | import { computed } from '@ember/object'; 53 | import { inject as service } from '@ember/service'; 54 | import FetchService from 'ember-ajax-fetch/services/fetch'; 55 | 56 | export default class ExtendedFetchService extends FetchService { 57 | @service session; 58 | 59 | @computed('session.authToken') 60 | get headers() { 61 | let headers = {}; 62 | const authToken = this.get('session.authToken'); 63 | if (authToken) { 64 | headers['auth-token'] = authToken; 65 | } 66 | return headers; 67 | } 68 | } 69 | ``` 70 | 71 | Headers by default are only passed if the hosts match, or the request is a relative path. 72 | You can overwrite this behavior by either passing a host in with the request, setting the 73 | host for the fetch service, or by setting an array of `trustedHosts` that can be either 74 | an array of strings or regexes. 75 | 76 | ```js 77 | // app/services/fetch.js 78 | 79 | import FetchService from 'ember-ajax-fetch/services/fetch'; 80 | 81 | export default class ExtendedFetchService extends FetchService { 82 | trustedHosts = [/\.example\./, 'foo.bar.com']; 83 | } 84 | ``` 85 | 86 | ### Custom Endpoint Path 87 | 88 | The `namespace` property can be used to prefix requests with a specific url namespace. 89 | 90 | ```js 91 | // app/services/fetch.js 92 | 93 | import FetchService from 'ember-ajax-fetch/services/fetch'; 94 | 95 | export default class ExtendedFetchService extends FetchService { 96 | namespace = '/api/v1'; 97 | } 98 | ``` 99 | 100 | `request('/users/me')` would now target `/api/v1/users/me` 101 | 102 | If you need to override the namespace for a custom request, use the `namespace` as an option to the request methods. 103 | 104 | ```js 105 | // GET /api/legacy/users/me 106 | request('/users/me', { namespace: '/api/legacy' }); 107 | ``` 108 | 109 | ### Custom Host 110 | 111 | `ember-ajax-fetch` allows you to specify a host to be used with a request. This is 112 | especially helpful so you don't have to continually pass in the host along 113 | with the path, makes `request()` a bit cleaner. 114 | 115 | To include a custom host to be used with your requests, you can specify `host` 116 | property on the `Fetch Service`. 117 | 118 | ```js 119 | // app/services/fetch.js 120 | 121 | import FetchService from 'ember-ajax-fetch/services/fetch'; 122 | 123 | export default class ExtendedFetchService extends FetchService { 124 | host = 'http://api.example.com'; 125 | } 126 | ``` 127 | 128 | That allows you to only have to make a call to `request()` as such: 129 | 130 | ```js 131 | // GET http://api.example.com/users/me 132 | request('/users/me'); 133 | ``` 134 | 135 | ### Custom Content-Type 136 | 137 | `ember-ajax-fetch` allows you to specify a default [Content-Type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header to be used with a request. 138 | 139 | To include a custom Content-Type you can specify `contentType` property on the `Fetch Service`. 140 | 141 | ```js 142 | // app/services/fetch.js 143 | 144 | import FetchService from 'ember-ajax-fetch/services/fetch'; 145 | 146 | export default class ExtendedFetchService extends FetchService { 147 | contentType = 'application/json; charset=utf-8'; 148 | } 149 | ``` 150 | 151 | You can also override the Content-Type per `request` with the `options` parameter. 152 | 153 | ### Error handling 154 | 155 | `ember-ajax-fetch` provides built in error classes that you can use to check the error 156 | that was returned by the response. This allows you to restrict determination of 157 | error result to the service instead of sprinkling it around your code. 158 | 159 | #### Built in error types 160 | 161 | `ember-ajax-fetch` has built-in error types that will be returned from the service in the event of an error: 162 | 163 | - `BadRequestError` (400) 164 | - `UnauthorizedError`(401) 165 | - `ForbiddenError`(403) 166 | - `NotFoundError` (404) 167 | - `InvalidError`(422) 168 | - `ServerError` (5XX) 169 | - `AbortError` 170 | - `TimeoutError` 171 | 172 | All of the above errors are subtypes of `FetchError`. 173 | 174 | #### Error detection helpers 175 | 176 | `ember-ajax-fetch` uses the helper functions from `ember-fetch` for matching response errors to their respective `ember-ajax-fetch` error type. 177 | Each of the errors listed above has a corresponding `is*` function (e.g., `isBadRequestResponse`), which can be imported from 178 | `ember-fetch/errors` 179 | 180 | Use of these functions is **strongly encouraged** to help eliminate the need for boilerplate error detection code. 181 | 182 | ```js 183 | import Route from '@ember/routing/route'; 184 | import { inject as service } from '@ember/service'; 185 | import { 186 | isNotFoundResponse, 187 | isForbiddenResponse 188 | } from 'ember-fetch/errors'; 189 | import { isFetchError } from 'ember-ajax-fetch/errors'; 190 | 191 | export default Route.extend({ 192 | fetch: service(), 193 | model() { 194 | return this.fetch.request('/user/doesnotexist').catch(function(error) { 195 | if (isNotFoundResponse(error)) { 196 | // handle 404 errors here 197 | return; 198 | } 199 | 200 | if (isForbiddenResponse(error)) { 201 | // handle 403 errors here 202 | return; 203 | } 204 | 205 | if (isFetchError(error)) { 206 | // handle all other AjaxErrors here 207 | return; 208 | } 209 | 210 | // other errors are handled elsewhere 211 | throw error; 212 | }); 213 | } 214 | }); 215 | ``` 216 | 217 | If your errors aren't standard, the helper function for that error type can be used as the base to build your custom detection function. 218 | 219 | ## Options 220 | 221 | ### timeout 222 | 223 | Timeouts are not supported by `fetch` by default, but using an `AbortController`, we 224 | implemented support for them, so it's the same API as AJAX. 225 | 226 | ```js 227 | this.fetch.request('/foo/bar', { 228 | timeout: 500 229 | }); 230 | ``` 231 | -------------------------------------------------------------------------------- /tests/unit/errors-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | import { 4 | isAbortError, 5 | isBadRequestResponse, 6 | isConflictResponse, 7 | isForbiddenResponse, 8 | isGoneResponse, 9 | isInvalidResponse, 10 | isNotFoundResponse, 11 | isServerErrorResponse, 12 | isUnauthorizedResponse, 13 | } from 'ember-fetch/errors'; 14 | import { 15 | FetchError, 16 | InvalidError, 17 | UnauthorizedError, 18 | ForbiddenError, 19 | NotFoundError, 20 | GoneError, 21 | BadRequestError, 22 | ServerError, 23 | TimeoutError, 24 | AbortError, 25 | ConflictError, 26 | isFetchError, 27 | } from 'ember-ajax-fetch/errors'; 28 | 29 | module('Unit | Errors Test', function (hooks) { 30 | setupTest(hooks); 31 | 32 | test('FetchError', function (assert) { 33 | const error = new FetchError(); 34 | assert.ok(error instanceof Error); 35 | }); 36 | 37 | test('InvalidError', function (assert) { 38 | const error = new InvalidError(); 39 | assert.ok(error instanceof Error); 40 | assert.ok(error instanceof InvalidError); 41 | }); 42 | 43 | test('UnauthorizedError', function (assert) { 44 | const error = new UnauthorizedError(); 45 | assert.ok(error instanceof Error); 46 | assert.ok(error instanceof UnauthorizedError); 47 | }); 48 | 49 | test('ForbiddenError', function (assert) { 50 | const error = new ForbiddenError(); 51 | assert.ok(error instanceof Error); 52 | assert.ok(error instanceof ForbiddenError); 53 | }); 54 | 55 | test('NotFoundError', function (assert) { 56 | const error = new NotFoundError(); 57 | assert.ok(error instanceof Error); 58 | assert.ok(error instanceof NotFoundError); 59 | }); 60 | 61 | test('GoneError', function (assert) { 62 | const error = new GoneError(); 63 | assert.ok(error instanceof Error); 64 | assert.ok(error instanceof GoneError); 65 | }); 66 | 67 | test('BadRequestError', function (assert) { 68 | const error = new BadRequestError(); 69 | assert.ok(error instanceof Error); 70 | assert.ok(error instanceof BadRequestError); 71 | }); 72 | 73 | test('ServerError', function (assert) { 74 | const error = new ServerError(); 75 | assert.ok(error instanceof Error); 76 | assert.ok(error instanceof ServerError); 77 | }); 78 | 79 | test('TimeoutError', function (assert) { 80 | const error = new TimeoutError(); 81 | assert.ok(error instanceof Error); 82 | assert.ok(error instanceof TimeoutError); 83 | }); 84 | 85 | test('AbortError', function (assert) { 86 | const error = new AbortError(); 87 | assert.ok(error instanceof Error); 88 | assert.ok(error instanceof AbortError); 89 | }); 90 | 91 | test('ConflictError', function (assert) { 92 | const error = new ConflictError(); 93 | assert.ok(error instanceof Error); 94 | assert.ok(error instanceof ConflictError); 95 | }); 96 | 97 | module('isUnauthorizedResponse', function () { 98 | test('detects error code correctly', function (assert) { 99 | assert.ok(isUnauthorizedResponse({ status: 401 })); 100 | }); 101 | 102 | test('detects error class correctly', function (assert) { 103 | const error = new UnauthorizedError(); 104 | assert.ok(isUnauthorizedResponse(error)); 105 | }); 106 | }); 107 | 108 | module('isForbiddenResponse', function () { 109 | test('detects error code correctly', function (assert) { 110 | assert.ok(isForbiddenResponse({ status: 403 })); 111 | }); 112 | 113 | test('detects error class correctly', function (assert) { 114 | const error = new ForbiddenError(); 115 | assert.ok(isForbiddenResponse(error)); 116 | }); 117 | }); 118 | 119 | module('isNotFoundResponse', function () { 120 | test(': detects error code correctly', function (assert) { 121 | assert.ok(isNotFoundResponse({ status: 404 })); 122 | assert.notOk(isNotFoundResponse({ status: 400 })); 123 | }); 124 | 125 | test('detects error class correctly', function (assert) { 126 | const error = new NotFoundError(); 127 | const otherError = new Error(); 128 | assert.ok(isNotFoundResponse(error)); 129 | assert.notOk(isNotFoundResponse(otherError)); 130 | }); 131 | }); 132 | 133 | module('isGoneResponse', function () { 134 | test(': detects error code correctly', function (assert) { 135 | assert.ok(isGoneResponse({ status: 410 })); 136 | assert.notOk(isGoneResponse({ status: 400 })); 137 | }); 138 | 139 | test('detects error class correctly', function (assert) { 140 | const error = new GoneError(); 141 | const otherError = new Error(); 142 | assert.ok(isGoneResponse(error)); 143 | assert.notOk(isGoneResponse(otherError)); 144 | }); 145 | }); 146 | 147 | module('isInvalidResponse', function () { 148 | test('detects error code correctly', function (assert) { 149 | assert.ok(isInvalidResponse({ status: 422 })); 150 | }); 151 | 152 | test('detects error class correctly', function (assert) { 153 | const error = new InvalidError(); 154 | assert.ok(isInvalidResponse(error)); 155 | }); 156 | }); 157 | 158 | module('isBadRequestResponse', function () { 159 | test('detects error code correctly', function (assert) { 160 | assert.ok(isBadRequestResponse({ status: 400 })); 161 | }); 162 | 163 | test('detects error class correctly', function (assert) { 164 | const error = new BadRequestError(); 165 | assert.ok(isBadRequestResponse(error)); 166 | }); 167 | }); 168 | 169 | module('isServerErrorResponse', function () { 170 | test('detects error code correctly', function (assert) { 171 | assert.notOk( 172 | isServerErrorResponse({ status: 499 }), 173 | '499 is not a server error', 174 | ); 175 | assert.ok( 176 | isServerErrorResponse({ status: 500 }), 177 | '500 is a server error', 178 | ); 179 | assert.ok( 180 | isServerErrorResponse({ status: 599 }), 181 | '599 is a server error', 182 | ); 183 | assert.notOk( 184 | isServerErrorResponse({ status: 600 }), 185 | '600 is not a server error', 186 | ); 187 | }); 188 | }); 189 | 190 | module('isFetchError', function () { 191 | test('detects error class correctly', function (assert) { 192 | const ajaxError = new FetchError(); 193 | const notAjaxError = new Error(); 194 | const ajaxErrorSubtype = new BadRequestError(); 195 | assert.ok(isFetchError(ajaxError)); 196 | assert.notOk(isFetchError(notAjaxError)); 197 | assert.ok(isFetchError(ajaxErrorSubtype)); 198 | }); 199 | }); 200 | 201 | module('isAbortError', function () { 202 | test('detects error class correctly', function (assert) { 203 | const error = new AbortError(); 204 | assert.ok(isAbortError(error)); 205 | }); 206 | }); 207 | 208 | module('isConflictResponse', function () { 209 | test('detects error code correctly', function (assert) { 210 | assert.ok(isConflictResponse({ status: 409 })); 211 | }); 212 | }); 213 | }); 214 | 215 | // describe('unit/errors-test - FetchError', function() { 216 | // 217 | // test('isTimeoutError: detects error class correctly', function() { 218 | // const error = new TimeoutError(); 219 | // assert.ok(isTimeoutError(error)); 220 | // }); 221 | // 222 | // describe('isSuccess', function() { 223 | // test('detects successful request correctly', function() { 224 | // notOk(isSuccess(100)); 225 | // notOk(isSuccess(199)); 226 | // assert.ok(isSuccess(200)); 227 | // assert.ok(isSuccess(299)); 228 | // notOk(isSuccess(300)); 229 | // assert.ok(isSuccess(304)); 230 | // notOk(isSuccess(400)); 231 | // notOk(isSuccess(500)); 232 | // }); 233 | // }); 234 | // }); 235 | -------------------------------------------------------------------------------- /addon/mixins/fetch-request.js: -------------------------------------------------------------------------------- 1 | import { A } from '@ember/array'; 2 | import Mixin from '@ember/object/mixin'; 3 | import { get } from '@ember/object'; 4 | import { isEmpty } from '@ember/utils'; 5 | import fetch from 'fetch'; 6 | import param from 'jquery-param'; 7 | import { 8 | isAbortError, 9 | isBadRequestResponse, 10 | isConflictResponse, 11 | isForbiddenResponse, 12 | isGoneResponse, 13 | isInvalidResponse, 14 | isNotFoundResponse, 15 | isServerErrorResponse, 16 | isUnauthorizedResponse, 17 | } from 'ember-fetch/errors'; 18 | import { 19 | FetchError, 20 | UnauthorizedError, 21 | InvalidError, 22 | ForbiddenError, 23 | BadRequestError, 24 | NotFoundError, 25 | GoneError, 26 | AbortError, 27 | ConflictError, 28 | ServerError, 29 | } from 'ember-ajax-fetch/errors'; 30 | import { 31 | endsWithSlash, 32 | haveSameHost, 33 | isFullURL, 34 | parseURL, 35 | removeLeadingSlash, 36 | removeTrailingSlash, 37 | startsWithSlash, 38 | stripSlashes, 39 | } from 'ember-ajax-fetch/-private/utils/url-helpers'; 40 | import isString from 'ember-ajax-fetch/-private/utils/is-string'; 41 | import { 42 | isJsonString, 43 | parseJSON, 44 | } from 'ember-ajax-fetch/-private/utils/json-helpers'; 45 | 46 | /** 47 | * @class FetchRequestMixin 48 | */ 49 | export default Mixin.create({ 50 | /** 51 | * The default value for the request `contentType` 52 | * 53 | * For now, defaults to the same value that jQuery would assign. In the 54 | * future, the default value will be for JSON requests. 55 | * @property {string} contentType 56 | * @public 57 | */ 58 | contentType: 'application/x-www-form-urlencoded; charset=UTF-8', 59 | 60 | /** 61 | * Make a fetch request, returning the raw fetch response 62 | * 63 | * Unlike ember-ajax this method returns the raw fetch response not an xhr 64 | * @method request 65 | * @param {string} url The url for the request 66 | * @param {object} options The options hash for the request 67 | * @return {object} containing {response, requestOptions, builtURL} 68 | */ 69 | async raw(url, options = {}) { 70 | const hash = this.options(url, options); 71 | const method = hash.method || hash.type || 'GET'; 72 | const requestOptions = { 73 | method, 74 | headers: { 75 | ...(hash.headers || {}), 76 | }, 77 | }; 78 | 79 | const abortController = new AbortController(); 80 | requestOptions.signal = abortController.signal; 81 | 82 | // If `contentType` is set to false, we want to not send anything and let the browser decide 83 | // We also want to ensure that no content-type was manually set on options.headers before overwriting it 84 | if ( 85 | options.contentType !== false && 86 | isEmpty(requestOptions.headers['Content-Type']) 87 | ) { 88 | requestOptions.headers['Content-Type'] = hash.contentType; 89 | } 90 | 91 | let builtURL = hash.url; 92 | if (hash.data) { 93 | let { data } = hash; 94 | 95 | if (options.processData === false) { 96 | requestOptions.body = data; 97 | } else { 98 | if (isJsonString(data)) { 99 | data = JSON.parse(data); 100 | } 101 | 102 | if (requestOptions.method === 'GET') { 103 | builtURL = `${builtURL}?${param(data)}`; 104 | } else { 105 | requestOptions.body = JSON.stringify(data); 106 | } 107 | } 108 | } 109 | 110 | try { 111 | // Used to manually pass another AbortController signal in, for external aborting 112 | if (options.signal) { 113 | options.signal.addEventListener('abort', () => abortController.abort()); 114 | } 115 | let timeout; 116 | if (options.timeout) { 117 | timeout = setTimeout(() => abortController.abort(), options.timeout); 118 | } 119 | let response = await fetch(builtURL, requestOptions); 120 | if (timeout) { 121 | clearTimeout(timeout); 122 | } 123 | 124 | return { response, requestOptions, builtURL }; 125 | } catch (error) { 126 | // TODO: do we want to just throw here or should some errors be okay? 127 | throw error; 128 | } 129 | }, 130 | 131 | /** 132 | * Make a fetch request, ignoring the raw fetch response and dealing only with 133 | * the response content 134 | * @method request 135 | * @param {string} url The url for the request 136 | * @param {object} options The options hash for the request 137 | * @return {Promise<*>} 138 | */ 139 | async request(url, options = {}) { 140 | let { response, requestOptions, builtURL } = await this.raw(url, options); 141 | response = await parseJSON(response); 142 | 143 | return this._handleResponse(response, requestOptions, builtURL); 144 | }, 145 | 146 | /** 147 | * Determine whether the headers should be added for this request 148 | * 149 | * This hook is used to help prevent sending headers to every host, regardless 150 | * of the destination, since this could be a security issue if authentication 151 | * tokens are accidentally leaked to third parties. 152 | * 153 | * To avoid that problem, subclasses should utilize the `headers` computed 154 | * property to prevent authentication from being sent to third parties, or 155 | * implement this hook for more fine-grain control over when headers are sent. 156 | * 157 | * By default, the headers are sent if the host of the request matches the 158 | * `host` property designated on the class. 159 | * 160 | * @method _shouldSendHeaders 161 | * @return {boolean|*} 162 | * @private 163 | */ 164 | _shouldSendHeaders({ url, host }) { 165 | url = url || ''; 166 | host = host || get(this, 'host') || ''; 167 | 168 | const trustedHosts = get(this, 'trustedHosts') || A(); 169 | const { hostname } = parseURL(url); 170 | 171 | // Add headers on relative URLs 172 | if (!isFullURL(url)) { 173 | return true; 174 | } else if ( 175 | trustedHosts.find((matcher) => this._matchHosts(hostname, matcher)) 176 | ) { 177 | return true; 178 | } 179 | 180 | // Add headers on matching host 181 | return haveSameHost(url, host); 182 | }, 183 | 184 | /** 185 | * Generates a detailed ("friendly") error message, with plenty 186 | * of information for debugging (good luck!) 187 | */ 188 | generateDetailedMessage(status, payload, contentType, type, url) { 189 | let shortenedPayload; 190 | const payloadContentType = contentType || 'Empty Content-Type'; 191 | 192 | if ( 193 | payloadContentType.toLowerCase() === 'text/html' && 194 | payload.length > 250 195 | ) { 196 | shortenedPayload = '[Omitted Lengthy HTML]'; 197 | } else { 198 | shortenedPayload = JSON.stringify(payload); 199 | } 200 | 201 | const requestDescription = `${type} ${url}`; 202 | const payloadDescription = `Payload (${payloadContentType})`; 203 | 204 | return [ 205 | `Ember Ajax Fetch Request ${requestDescription} returned a ${status}`, 206 | payloadDescription, 207 | shortenedPayload, 208 | ].join('\n'); 209 | }, 210 | 211 | /** 212 | * Created a normalized set of options from the per-request and 213 | * service-level settings 214 | * @method options 215 | * @param {string} url The url for the request 216 | * @param {object} options The options hash for the request 217 | * @return {object} 218 | */ 219 | options(url, options = {}) { 220 | options = Object.assign({}, options); 221 | options.url = this._buildURL(url, options); 222 | options.type = options.type || 'GET'; 223 | options.dataType = options.dataType || 'json'; 224 | options.contentType = isEmpty(options.contentType) 225 | ? get(this, 'contentType') 226 | : options.contentType; 227 | 228 | if (this._shouldSendHeaders(options)) { 229 | options.headers = this._getFullHeadersHash(options.headers); 230 | } else { 231 | options.headers = options.headers || {}; 232 | } 233 | 234 | return options; 235 | }, 236 | 237 | /** 238 | * Build the URL to pass to `fetch` 239 | * @method _buildURL 240 | * @param {string} url The base url 241 | * @param {object} options The options to pass to fetch, query params, headers, etc 242 | * @return {string} The built url 243 | * @private 244 | */ 245 | _buildURL(url, options = {}) { 246 | if (isFullURL(url)) { 247 | return url; 248 | } 249 | 250 | const urlParts = []; 251 | 252 | let host = options.host || get(this, 'host'); 253 | if (host) { 254 | host = endsWithSlash(host) ? removeTrailingSlash(host) : host; 255 | urlParts.push(host); 256 | } 257 | 258 | let namespace = options.namespace || get(this, 'namespace'); 259 | if (namespace) { 260 | // If host is given then we need to strip leading slash too( as it will be added through join) 261 | if (host) { 262 | namespace = stripSlashes(namespace); 263 | } else if (endsWithSlash(namespace)) { 264 | namespace = removeTrailingSlash(namespace); 265 | } 266 | 267 | const hasNamespaceRegex = new RegExp(`^(/)?${stripSlashes(namespace)}/`); 268 | if (!hasNamespaceRegex.test(url)) { 269 | urlParts.push(namespace); 270 | } 271 | } 272 | 273 | // *Only* remove a leading slash -- we need to maintain a trailing slash for 274 | // APIs that differentiate between it being and not being present 275 | if (startsWithSlash(url) && urlParts.length !== 0) { 276 | url = removeLeadingSlash(url); 277 | } 278 | urlParts.push(url); 279 | 280 | return urlParts.join('/'); 281 | }, 282 | 283 | /** 284 | * Return the correct error type 285 | * @method _createCorrectError 286 | * @param {object} response The response from the fetch call 287 | * @param {*} payload The response.json() payload 288 | * @param {object} requestOptions The options object containing headers, method, etc 289 | * @param {string} url The url string 290 | * @private 291 | */ 292 | _createCorrectError(response, payload, requestOptions, url) { 293 | let error; 294 | 295 | if (isUnauthorizedResponse(response)) { 296 | error = new UnauthorizedError(payload); 297 | } else if (isForbiddenResponse(response)) { 298 | error = new ForbiddenError(payload); 299 | } else if (isInvalidResponse(response)) { 300 | error = new InvalidError(payload); 301 | } else if (isBadRequestResponse(response)) { 302 | error = new BadRequestError(payload); 303 | } else if (isNotFoundResponse(response)) { 304 | error = new NotFoundError(payload); 305 | } else if (isGoneResponse(response)) { 306 | error = new GoneError(payload); 307 | } else if (isAbortError(response)) { 308 | error = new AbortError(); 309 | } else if (isConflictResponse(response)) { 310 | error = new ConflictError(payload); 311 | } else if (isServerErrorResponse(response)) { 312 | error = new ServerError(payload, response.status); 313 | } else { 314 | const detailedMessage = this.generateDetailedMessage( 315 | response.status, 316 | payload, 317 | requestOptions.headers['Content-Type'], 318 | requestOptions.method, 319 | url, 320 | ); 321 | 322 | error = new FetchError(payload, detailedMessage, response.status); 323 | } 324 | 325 | return error; 326 | }, 327 | 328 | /** 329 | * Calls `request()` but forces `options.type` to `POST` 330 | * @method post 331 | * @param {string} url The url for the request 332 | * @param {object} options The options object for the request 333 | * @return {*|Promise<*>} 334 | */ 335 | post(url, options) { 336 | return this.request(url, this._addTypeToOptionsFor(options, 'POST')); 337 | }, 338 | 339 | /** 340 | * Calls `request()` but forces `options.type` to `PUT` 341 | * @method put 342 | * @param {string} url The url for the request 343 | * @param {object} options The options object for the request 344 | * @return {*|Promise<*>} 345 | */ 346 | put(url, options) { 347 | return this.request(url, this._addTypeToOptionsFor(options, 'PUT')); 348 | }, 349 | 350 | /** 351 | * Calls `request()` but forces `options.type` to `PATCH` 352 | * @method patch 353 | * @param {string} url The url for the request 354 | * @param {object} options The options object for the request 355 | * @return {*|Promise<*>} 356 | */ 357 | patch(url, options) { 358 | return this.request(url, this._addTypeToOptionsFor(options, 'PATCH')); 359 | }, 360 | 361 | /** 362 | * Calls `request()` but forces `options.type` to `DELETE` 363 | * @method del 364 | * @param {string} url The url for the request 365 | * @param {object} options The options object for the request 366 | * @return {*|Promise<*>} 367 | */ 368 | del(url, options) { 369 | return this.request(url, this._addTypeToOptionsFor(options, 'DELETE')); 370 | }, 371 | 372 | /** 373 | * Calls `request()` but forces `options.type` to `DELETE` 374 | * 375 | * Alias for `del()` 376 | * @method delete 377 | * @param {string} url The url for the request 378 | * @param {object} options The options object for the request 379 | * @return {*|Promise<*>} 380 | */ 381 | delete(url, options) { 382 | return this.del(url, options); 383 | }, 384 | 385 | /** 386 | * Wrap the `.get` method so that we issue a warning if 387 | * 388 | * Since `.get` is both an AJAX pattern _and_ an Ember pattern, we want to try 389 | * to warn users when they try using `.get` to make a request 390 | * @param {string} url 391 | * @return {*} 392 | */ 393 | get(url) { 394 | if (arguments.length > 1 || url.indexOf('/') !== -1) { 395 | throw new Error( 396 | 'It seems you tried to use `.get` to make a request! Use the `.request` method instead.', 397 | ); 398 | } 399 | return this._super(...arguments); 400 | }, 401 | 402 | /** 403 | * Manipulates the options hash to include the HTTP method on the type key 404 | * @method _addTypeOptionsFor 405 | * @param {object} options The request options hash 406 | * @param {string} method The type of request. GET, POST, etc 407 | * @return {*|{}} 408 | * @private 409 | */ 410 | _addTypeToOptionsFor(options, method) { 411 | options = options || {}; 412 | options.type = method; 413 | return options; 414 | }, 415 | 416 | /** 417 | * Get the full "headers" hash, combining the service-defined headers with 418 | * the ones provided for the request 419 | * @method _getFullHeadersHash 420 | * @param {object} headers The headers passed in the options hash 421 | * @private 422 | */ 423 | _getFullHeadersHash(headers) { 424 | const classHeaders = get(this, 'headers'); 425 | return Object.assign({}, classHeaders, headers); 426 | }, 427 | 428 | /** 429 | * Return the response or handle the error 430 | * @method _handleResponse 431 | * @param {object} response The response from the request 432 | * @param {object} requestOptions The options object containing headers, method, etc 433 | * @param {string} url The url for the request 434 | * @return {*} 435 | * @private 436 | */ 437 | _handleResponse(response, requestOptions, url) { 438 | if (response.ok) { 439 | return response.text || response.json || response.blob; 440 | } else { 441 | throw this._createCorrectError( 442 | response, 443 | response.payload, 444 | requestOptions, 445 | url, 446 | ); 447 | } 448 | }, 449 | 450 | /** 451 | * Match the host to a provided array of strings or regexes that can match to a host 452 | * @method _matchHosts 453 | * @param {string|undefined} host 454 | * @param {string} matcher 455 | * @private 456 | */ 457 | _matchHosts(host, matcher) { 458 | if (!isString(host)) { 459 | return false; 460 | } 461 | 462 | if (matcher instanceof RegExp) { 463 | return matcher.test(host); 464 | } else if (typeof matcher === 'string') { 465 | return matcher === host; 466 | } else { 467 | console.warn( 468 | 'trustedHosts only handles strings or regexes. ', 469 | matcher, 470 | ' is neither.', 471 | ); 472 | return false; 473 | } 474 | }, 475 | }); 476 | -------------------------------------------------------------------------------- /tests/unit/mixins/fetch-request-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/avoid-leaking-state-in-ember-objects */ 2 | import { A } from '@ember/array'; 3 | import { typeOf } from '@ember/utils'; 4 | import { encode as base64Encode } from 'base-64'; 5 | import FetchRequest from 'ember-ajax-fetch/fetch-request'; 6 | import Pretender from 'pretender'; 7 | import { module, test } from 'qunit'; 8 | import { setupTest } from 'ember-qunit'; 9 | import td from 'testdouble'; 10 | import { jsonFactory, jsonResponse } from 'dummy/tests/helpers/json'; 11 | // import { isTimeoutError } from 'ember-ajax-fetch/errors'; 12 | import { 13 | UnauthorizedError, 14 | InvalidError, 15 | ForbiddenError, 16 | BadRequestError, 17 | GoneError, 18 | ConflictError, 19 | ServerError, 20 | } from 'ember-ajax-fetch/errors'; 21 | 22 | const { 23 | matchers: { anything, contains: matchContains }, 24 | } = td; 25 | 26 | module('Unit | Mixin | fetch-request', function (hooks) { 27 | setupTest(hooks); 28 | 29 | hooks.beforeEach(function () { 30 | this.server = new Pretender(); 31 | }); 32 | 33 | hooks.afterEach(function () { 34 | this.server.shutdown(); 35 | }); 36 | 37 | module('options method', function () { 38 | test('sets raw data', function (assert) { 39 | const service = FetchRequest.create(); 40 | const url = '/test'; 41 | const type = 'GET'; 42 | const options = service.options(url, { 43 | type, 44 | data: { key: 'value' }, 45 | }); 46 | 47 | assert.deepEqual(options, { 48 | contentType: 'application/x-www-form-urlencoded; charset=UTF-8', 49 | data: { 50 | key: 'value', 51 | }, 52 | dataType: 'json', 53 | headers: {}, 54 | type: 'GET', 55 | url: '/test', 56 | }); 57 | }); 58 | 59 | test('sets options correctly', function (assert) { 60 | const service = FetchRequest.create(); 61 | const url = '/test'; 62 | const type = 'POST'; 63 | const data = JSON.stringify({ key: 'value' }); 64 | const options = service.options(url, { 65 | type, 66 | data, 67 | contentType: 'application/json; charset=utf-8', 68 | }); 69 | 70 | assert.deepEqual(options, { 71 | contentType: 'application/json; charset=utf-8', 72 | data: '{"key":"value"}', 73 | dataType: 'json', 74 | headers: {}, 75 | type: 'POST', 76 | url: '/test', 77 | }); 78 | }); 79 | 80 | test('does not modify the options object argument', function (assert) { 81 | const service = FetchRequest.create(); 82 | const url = 'test'; 83 | const data = JSON.stringify({ key: 'value' }); 84 | const baseOptions = { type: 'POST', data }; 85 | service.options(url, baseOptions); 86 | assert.deepEqual(baseOptions, { type: 'POST', data }); 87 | }); 88 | 89 | test('does not override contentType when defined', function (assert) { 90 | const service = FetchRequest.create(); 91 | const url = '/test'; 92 | const type = 'POST'; 93 | const data = JSON.stringify({ key: 'value' }); 94 | const options = service.options(url, { 95 | type, 96 | data, 97 | contentType: false, 98 | }); 99 | 100 | assert.deepEqual(options, { 101 | contentType: false, 102 | data: '{"key":"value"}', 103 | dataType: 'json', 104 | headers: {}, 105 | type: 'POST', 106 | url: '/test', 107 | }); 108 | }); 109 | 110 | test('can handle empty data', function (assert) { 111 | const service = FetchRequest.create(); 112 | const url = '/test'; 113 | const type = 'POST'; 114 | const options = service.options(url, { type }); 115 | 116 | assert.deepEqual(options, { 117 | contentType: 'application/x-www-form-urlencoded; charset=UTF-8', 118 | dataType: 'json', 119 | headers: {}, 120 | type: 'POST', 121 | url: '/test', 122 | }); 123 | }); 124 | 125 | test('is only called once per call to request', function (assert) { 126 | let numberOptionsCalls = 0; 127 | 128 | this.server.get('/foo', () => jsonResponse()); 129 | 130 | const MonitorOptionsCalls = FetchRequest.extend({ 131 | options() { 132 | numberOptionsCalls = numberOptionsCalls + 1; 133 | return this._super(...arguments); 134 | }, 135 | }); 136 | 137 | const service = MonitorOptionsCalls.create(); 138 | return service.request('/foo').then(function () { 139 | assert.strictEqual(numberOptionsCalls, 1); 140 | }); 141 | }); 142 | 143 | module('host', function () { 144 | test('is set on the url (url starting with `/`)', function (assert) { 145 | const RequestWithHost = FetchRequest.extend({ 146 | host: 'https://discuss.emberjs.com', 147 | }); 148 | 149 | const service = RequestWithHost.create(); 150 | const url = '/users/me'; 151 | const options = service.options(url); 152 | 153 | assert.strictEqual(options.url, 'https://discuss.emberjs.com/users/me'); 154 | }); 155 | 156 | test('is set on the url (url not starting with `/`)', function (assert) { 157 | const RequestWithHost = FetchRequest.extend({ 158 | host: 'https://discuss.emberjs.com', 159 | }); 160 | 161 | const service = RequestWithHost.create(); 162 | const url = 'users/me'; 163 | const options = service.options(url); 164 | 165 | assert.strictEqual(options.url, 'https://discuss.emberjs.com/users/me'); 166 | }); 167 | 168 | test('is overridable on a per-request basis', function (assert) { 169 | const RequestWithHost = FetchRequest.extend({ 170 | host: 'https://discuss.emberjs.com', 171 | }); 172 | 173 | const service = RequestWithHost.create(); 174 | const url = 'users/me'; 175 | const host = 'https://myurl.com'; 176 | const options = service.options(url, { host }); 177 | 178 | assert.strictEqual(options.url, 'https://myurl.com/users/me'); 179 | }); 180 | 181 | test('is set on the namespace(namespace not starting with `/`)', function (assert) { 182 | const RequestWithHostAndNamespace = FetchRequest.extend({ 183 | host: 'https://discuss.emberjs.com', 184 | namespace: 'api/v1', 185 | }); 186 | const service = RequestWithHostAndNamespace.create(); 187 | const url = 'users/me'; 188 | const options = service.options(url); 189 | 190 | assert.strictEqual( 191 | options.url, 192 | 'https://discuss.emberjs.com/api/v1/users/me', 193 | ); 194 | }); 195 | 196 | test('is set on the namespace(namespace starting with `/`)', function (assert) { 197 | const RequestWithHostAndNamespace = FetchRequest.extend({ 198 | host: 'https://discuss.emberjs.com', 199 | namespace: '/api/v1', 200 | }); 201 | const service = RequestWithHostAndNamespace.create(); 202 | const url = 'users/me'; 203 | const options = service.options(url); 204 | 205 | assert.strictEqual( 206 | options.url, 207 | 'https://discuss.emberjs.com/api/v1/users/me', 208 | ); 209 | }); 210 | 211 | test('is set on the url containing namespace', function (assert) { 212 | const RequestWithHostAndNamespace = FetchRequest.extend({ 213 | host: 'https://discuss.emberjs.com', 214 | namespace: '/api/v1', 215 | }); 216 | const service = RequestWithHostAndNamespace.create(); 217 | 218 | assert.strictEqual( 219 | service.options('/api/v1/users/me').url, 220 | 'https://discuss.emberjs.com/api/v1/users/me', 221 | ); 222 | assert.strictEqual( 223 | service.options('api/v1/users/me').url, 224 | 'https://discuss.emberjs.com/api/v1/users/me', 225 | ); 226 | }); 227 | 228 | test('is set on the url containing namespace no leading slash', function (assert) { 229 | const RequestWithHostAndNamespace = FetchRequest.extend({ 230 | host: 'https://discuss.emberjs.com', 231 | namespace: 'api/v1', 232 | }); 233 | const service = RequestWithHostAndNamespace.create(); 234 | 235 | assert.strictEqual( 236 | service.options('/api/v1/users/me').url, 237 | 'https://discuss.emberjs.com/api/v1/users/me', 238 | ); 239 | assert.strictEqual( 240 | service.options('api/v1/users/me').url, 241 | 'https://discuss.emberjs.com/api/v1/users/me', 242 | ); 243 | }); 244 | 245 | test('is set with the host address as `//` and url not starting with `/`', function (assert) { 246 | const RequestWithHostAndNamespace = FetchRequest.extend({ 247 | host: '//', 248 | }); 249 | const service = RequestWithHostAndNamespace.create(); 250 | const url = 'users/me'; 251 | const options = service.options(url); 252 | 253 | assert.strictEqual(options.url, '//users/me'); 254 | }); 255 | 256 | test('is set with the host address as `//` and url starting with `/`', function (assert) { 257 | const RequestWithHostAndNamespace = FetchRequest.extend({ 258 | host: '//', 259 | }); 260 | const service = RequestWithHostAndNamespace.create(); 261 | const url = '/users/me'; 262 | const options = service.options(url); 263 | 264 | assert.strictEqual(options.url, '//users/me'); 265 | }); 266 | }); 267 | 268 | module('namespace', function () { 269 | test('is set on the url (namespace starting with `/`)', function (assert) { 270 | const RequestWithHost = FetchRequest.extend({ 271 | namespace: '/api/v1', 272 | }); 273 | 274 | const service = RequestWithHost.create(); 275 | 276 | assert.strictEqual( 277 | service.options('/users/me').url, 278 | '/api/v1/users/me', 279 | ); 280 | assert.strictEqual(service.options('users/me').url, '/api/v1/users/me'); 281 | }); 282 | 283 | test('can be set on a per-request basis', function (assert) { 284 | const service = FetchRequest.create(); 285 | 286 | assert.strictEqual( 287 | service.options('users/me', { namespace: '/api' }).url, 288 | '/api/users/me', 289 | ); 290 | assert.strictEqual( 291 | service.options('users/me', { namespace: 'api' }).url, 292 | 'api/users/me', 293 | ); 294 | }); 295 | 296 | test('is set on the url (namespace not starting with `/`)', function (assert) { 297 | const RequestWithHost = FetchRequest.extend({ 298 | namespace: 'api/v1', 299 | }); 300 | 301 | const service = RequestWithHost.create(); 302 | 303 | assert.strictEqual(service.options('/users/me').url, 'api/v1/users/me'); 304 | assert.strictEqual(service.options('users/me').url, 'api/v1/users/me'); 305 | }); 306 | }); 307 | 308 | module('type', function () { 309 | test('defaults to GET', function (assert) { 310 | const service = FetchRequest.create(); 311 | const url = 'test'; 312 | const options = service.options(url); 313 | 314 | assert.strictEqual(options.type, 'GET'); 315 | }); 316 | }); 317 | }); 318 | 319 | test('can override the default `contentType` for the service', function (assert) { 320 | const defaultContentType = 'application/json'; 321 | 322 | class FetchServiceWithDefaultContentType extends FetchRequest { 323 | get contentType() { 324 | return defaultContentType; 325 | } 326 | } 327 | 328 | const service = FetchServiceWithDefaultContentType.create(); 329 | const options = service.options(''); 330 | assert.strictEqual(options.contentType, defaultContentType); 331 | }); 332 | 333 | test('raw() response.post === options.data.post', function (assert) { 334 | const service = FetchRequest.create(); 335 | const url = '/posts'; 336 | const title = 'Title'; 337 | const description = 'Some description.'; 338 | const contentType = 'application/json'; 339 | const customHeader = 'My custom header'; 340 | const options = { 341 | data: { 342 | post: { title, description }, 343 | }, 344 | }; 345 | const serverResponse = [ 346 | 200, 347 | { 'Content-Type': contentType, 'Custom-Header': customHeader }, 348 | JSON.stringify(options.data), 349 | ]; 350 | 351 | this.server.get(url, () => serverResponse); 352 | 353 | const rawPromise = service.raw(url, options); 354 | 355 | return rawPromise 356 | .then(function ({ response }) { 357 | assert.strictEqual(response.status, 200); 358 | assert.strictEqual(response.headers.get('Custom-Header'), customHeader); 359 | assert.strictEqual(response.headers.get('Content-Type'), contentType); 360 | return response.json(); 361 | }) 362 | .then((json) => { 363 | assert.deepEqual(json.post, options.data.post); 364 | }); 365 | }); 366 | 367 | test('post() response.post === options.data.post', function (assert) { 368 | const service = FetchRequest.create(); 369 | const url = '/posts'; 370 | const title = 'Title'; 371 | const description = 'Some description.'; 372 | const options = { 373 | data: { 374 | post: { title, description }, 375 | }, 376 | }; 377 | const serverResponse = [ 378 | 200, 379 | { 'Content-Type': 'application/json' }, 380 | JSON.stringify(options.data), 381 | ]; 382 | 383 | this.server.post(url, () => serverResponse); 384 | 385 | const postPromise = service.post(url, options); 386 | 387 | return postPromise.then(function (response) { 388 | assert.deepEqual(response.post, options.data.post); 389 | }); 390 | }); 391 | 392 | test('post() application/zip response returns Blob', function (assert) { 393 | const service = FetchRequest.create(); 394 | const url = '/posts'; 395 | const dummyZipData = new Uint8Array([80, 75, 3, 4]); 396 | const base64Zip = base64Encode(String.fromCharCode(...dummyZipData)); 397 | const serverResponse = [ 398 | 200, 399 | { 'Content-Type': 'application/zip' }, 400 | base64Zip, 401 | ]; 402 | 403 | this.server.post(url, () => serverResponse); 404 | 405 | const postPromise = service.post(url, {}); 406 | 407 | return postPromise.then(function (response) { 408 | assert.ok(response instanceof Blob, 'Response is an ArrayBuffer'); 409 | }); 410 | }); 411 | 412 | test('put() response.post === options.data.post', function (assert) { 413 | const service = FetchRequest.create(); 414 | const url = '/posts/1'; 415 | const title = 'Title'; 416 | const description = 'Some description.'; 417 | const id = 1; 418 | const options = { 419 | data: { 420 | post: { id, title, description }, 421 | }, 422 | }; 423 | 424 | const serverResponse = [ 425 | 200, 426 | { 'Content-Type': 'application/json' }, 427 | JSON.stringify(options.data), 428 | ]; 429 | 430 | this.server.put(url, () => serverResponse); 431 | 432 | const putPromise = service.put(url, options); 433 | 434 | return putPromise.then(function (response) { 435 | assert.deepEqual(response.post, options.data.post); 436 | }); 437 | }); 438 | 439 | test('patch() response.post === options.data.post', function (assert) { 440 | const service = FetchRequest.create(); 441 | const url = '/posts/1'; 442 | const description = 'Some description.'; 443 | const options = { 444 | data: { 445 | post: { description }, 446 | }, 447 | }; 448 | 449 | const serverResponse = [ 450 | 200, 451 | { 'Content-Type': 'application/json' }, 452 | JSON.stringify(options.data), 453 | ]; 454 | 455 | this.server.patch(url, () => serverResponse); 456 | 457 | const patchPromise = service.patch(url, options); 458 | 459 | return patchPromise.then(function (response) { 460 | assert.deepEqual(response.post, options.data.post); 461 | }); 462 | }); 463 | 464 | test('del() response is {}', function (assert) { 465 | const service = FetchRequest.create(); 466 | const url = '/posts/1'; 467 | const serverResponse = [ 468 | 200, 469 | { 'Content-Type': 'application/json' }, 470 | JSON.stringify({}), 471 | ]; 472 | 473 | this.server.delete(url, () => serverResponse); 474 | 475 | const delPromise = service.del(url); 476 | 477 | return delPromise.then(function (response) { 478 | assert.deepEqual(response, {}); 479 | }); 480 | }); 481 | 482 | test('delete() response is {}', function (assert) { 483 | const service = FetchRequest.create(); 484 | const url = '/posts/1'; 485 | const serverResponse = [ 486 | 200, 487 | { 'Content-Type': 'application/json' }, 488 | JSON.stringify({}), 489 | ]; 490 | 491 | this.server.delete(url, () => serverResponse); 492 | 493 | const deletePromise = service.delete(url); 494 | 495 | return deletePromise.then(function (response) { 496 | assert.deepEqual(response, {}); 497 | }); 498 | }); 499 | 500 | test('request with method option makes the correct type of request', function (assert) { 501 | const url = '/posts/1'; 502 | const serverResponse = [ 503 | 200, 504 | { 'Content-Type': 'application/json' }, 505 | JSON.stringify({}), 506 | ]; 507 | 508 | this.server.get(url, () => { 509 | throw new Error("Shouldn't make an AJAX request"); 510 | }); 511 | this.server.post(url, () => serverResponse); 512 | 513 | const service = FetchRequest.create(); 514 | const _handleResponse = td.function('handle response'); 515 | const expectedArguments = [ 516 | anything(), 517 | matchContains({ method: 'POST' }), 518 | anything(), 519 | ]; 520 | service._handleResponse = _handleResponse; 521 | td.when(_handleResponse(...expectedArguments)).thenReturn({}); 522 | 523 | return service.request(url, { method: 'POST' }).then(() => { 524 | assert.verify(_handleResponse(...expectedArguments)); 525 | }); 526 | }); 527 | 528 | module('explicit host in URL', function () { 529 | test('overrides host property of class', function (assert) { 530 | const RequestWithHost = FetchRequest.extend({ 531 | host: 'https://discuss.emberjs.com', 532 | }); 533 | 534 | const service = RequestWithHost.create(); 535 | const url = 'http://myurl.com/users/me'; 536 | const options = service.options(url); 537 | 538 | assert.strictEqual(options.url, 'http://myurl.com/users/me'); 539 | }); 540 | 541 | test('overrides host property in request config', function (assert) { 542 | const service = FetchRequest.create(); 543 | const host = 'https://discuss.emberjs.com'; 544 | const url = 'http://myurl.com/users/me'; 545 | const options = service.options(url, { host }); 546 | 547 | assert.strictEqual(options.url, 'http://myurl.com/users/me'); 548 | }); 549 | 550 | test('without a protocol does not override config property', function (assert) { 551 | const RequestWithHost = FetchRequest.extend({ 552 | host: 'https://discuss.emberjs.com', 553 | }); 554 | 555 | const service = RequestWithHost.create(); 556 | const url = 'myurl.com/users/me'; 557 | const options = service.options(url); 558 | 559 | assert.strictEqual( 560 | options.url, 561 | 'https://discuss.emberjs.com/myurl.com/users/me', 562 | ); 563 | }); 564 | }); 565 | 566 | module('headers', function () { 567 | test('is set if the URL matches the host', function (assert) { 568 | this.server.get('http://example.com/test', (req) => { 569 | const { requestHeaders } = req; 570 | assert.strictEqual(requestHeaders['Content-Type'], 'application/json'); 571 | assert.strictEqual(requestHeaders['Other-key'], 'Other Value'); 572 | return jsonResponse(); 573 | }); 574 | 575 | const RequestWithHeaders = FetchRequest.extend({ 576 | host: 'http://example.com', 577 | headers: { 578 | 'Content-Type': 'application/json', 579 | 'Other-key': 'Other Value', 580 | }, 581 | }); 582 | 583 | const service = RequestWithHeaders.create(); 584 | return service.request('http://example.com/test'); 585 | }); 586 | 587 | test('is set if the URL is relative', function (assert) { 588 | this.server.get('/some/relative/url', (req) => { 589 | const { requestHeaders } = req; 590 | assert.strictEqual(requestHeaders['Content-Type'], 'application/json'); 591 | assert.strictEqual(requestHeaders['Other-key'], 'Other Value'); 592 | return jsonResponse(); 593 | }); 594 | 595 | const RequestWithHeaders = FetchRequest.extend({ 596 | headers: { 597 | 'Content-Type': 'application/json', 598 | 'Other-key': 'Other Value', 599 | }, 600 | }); 601 | 602 | const service = RequestWithHeaders.create(); 603 | return service.request('/some/relative/url'); 604 | }); 605 | 606 | test('is set if the URL matches one of the RegExp trustedHosts', function (assert) { 607 | this.server.get('http://my.example.com', (req) => { 608 | const { requestHeaders } = req; 609 | assert.strictEqual(requestHeaders['Other-key'], 'Other Value'); 610 | return jsonResponse(); 611 | }); 612 | 613 | const RequestWithHeaders = FetchRequest.extend({ 614 | host: 'some-other-host.com', 615 | trustedHosts: A([4, 'notmy.example.com', /example\./]), 616 | headers: { 617 | 'Content-Type': 'application/json', 618 | 'Other-key': 'Other Value', 619 | }, 620 | }); 621 | 622 | const service = RequestWithHeaders.create(); 623 | return service.request('http://my.example.com'); 624 | }); 625 | 626 | test('is set if the URL matches one of the string trustedHosts', function (assert) { 627 | this.server.get('http://foo.bar.com', (req) => { 628 | const { requestHeaders } = req; 629 | assert.strictEqual(requestHeaders['Other-key'], 'Other Value'); 630 | return jsonResponse(); 631 | }); 632 | 633 | const RequestWithHeaders = FetchRequest.extend({ 634 | host: 'some-other-host.com', 635 | trustedHosts: A(['notmy.example.com', /example\./, 'foo.bar.com']), 636 | headers: { 637 | 'Content-Type': 'application/json', 638 | 'Other-key': 'Other Value', 639 | }, 640 | }); 641 | 642 | const service = RequestWithHeaders.create(); 643 | return service.request('http://foo.bar.com'); 644 | }); 645 | 646 | test('is not set if the URL does not match the host', function (assert) { 647 | this.server.get('http://example.com', (req) => { 648 | const { requestHeaders } = req; 649 | assert.notEqual(requestHeaders['Other-key'], 'Other Value'); 650 | return jsonResponse(); 651 | }); 652 | 653 | const RequestWithHeaders = FetchRequest.extend({ 654 | host: 'some-other-host.com', 655 | headers: { 656 | 'Content-Type': 'application/json', 657 | 'Other-key': 'Other Value', 658 | }, 659 | }); 660 | 661 | const service = RequestWithHeaders.create(); 662 | return service.request('http://example.com'); 663 | }); 664 | 665 | test('can be supplied on a per-request basis', function (assert) { 666 | this.server.get('http://example.com', (req) => { 667 | const { requestHeaders } = req; 668 | assert.strictEqual(requestHeaders['Per-Request-Key'], 'Some value'); 669 | assert.strictEqual(requestHeaders['Other-key'], 'Other Value'); 670 | return jsonResponse(); 671 | }); 672 | 673 | const RequestWithHeaders = FetchRequest.extend({ 674 | host: 'http://example.com', 675 | headers: { 676 | 'Content-Type': 'application/json', 677 | 'Other-key': 'Other Value', 678 | }, 679 | }); 680 | 681 | const service = RequestWithHeaders.create(); 682 | return service.request('http://example.com', { 683 | headers: { 684 | 'Per-Request-Key': 'Some value', 685 | }, 686 | }); 687 | }); 688 | 689 | test('can get the full list from class and request options', function (assert) { 690 | const RequestWithHeaders = FetchRequest.extend({ 691 | headers: { 692 | 'Content-Type': 'application/vnd.api+json', 693 | 'Other-Value': 'Some Value', 694 | }, 695 | }); 696 | 697 | const service = RequestWithHeaders.create(); 698 | const headers = { 'Third-Value': 'Other Thing' }; 699 | assert.strictEqual(Object.keys(service._getFullHeadersHash()).length, 2); 700 | assert.strictEqual( 701 | Object.keys(service._getFullHeadersHash(headers)).length, 702 | 3, 703 | ); 704 | assert.strictEqual(Object.keys(service.headers).length, 2); 705 | }); 706 | }); 707 | 708 | test('it creates a detailed error message for unmatched server errors with an AJAX payload', function (assert) { 709 | const response = [ 710 | 408, 711 | { 'Content-Type': 'application/json' }, 712 | JSON.stringify({ errors: ['Some error response'] }), 713 | ]; 714 | this.server.get('/posts', () => response); 715 | 716 | const service = FetchRequest.create(); 717 | return service 718 | .request('/posts') 719 | .then(function () { 720 | throw new Error('success handler should not be called'); 721 | }) 722 | .catch(function (result) { 723 | assert.ok(result.message.includes('Some error response')); 724 | assert.ok(result.message.includes('GET')); 725 | assert.ok(result.message.includes('/posts')); 726 | assert.strictEqual(result.status, 408); 727 | }); 728 | }); 729 | 730 | test('it creates a detailed error message for unmatched server errors with a text payload', function (assert) { 731 | const response = [ 732 | 408, 733 | { 'Content-Type': 'text/html' }, 734 | 'Some error response', 735 | ]; 736 | this.server.get('/posts', () => response); 737 | 738 | const service = FetchRequest.create(); 739 | return service 740 | .request('/posts') 741 | .then(function () { 742 | throw new Error('success handler should not be called'); 743 | }) 744 | .catch(function (result) { 745 | assert.ok(result.message.includes('Some error response')); 746 | assert.ok(result.message.includes('GET')); 747 | assert.ok(result.message.includes('/posts')); 748 | assert.strictEqual(result.status, 408); 749 | }); 750 | }); 751 | 752 | test('it throws an error when the user tries to use `.get` to make a request', function (assert) { 753 | const service = FetchRequest.create(); 754 | service.set('someProperty', 'foo'); 755 | 756 | assert.strictEqual(service.get('someProperty'), 'foo'); 757 | 758 | assert.throws(function () { 759 | service.get('/users'); 760 | }); 761 | 762 | assert.throws(function () { 763 | service.get('/users', {}); 764 | }); 765 | }); 766 | 767 | test('it JSON encodes JSON request data automatically per contentType', function (assert) { 768 | this.server.post('/test', ({ requestBody }) => { 769 | const { foo } = JSON.parse(requestBody); 770 | assert.strictEqual(foo, 'bar'); 771 | return jsonResponse(); 772 | }); 773 | 774 | const RequestWithHeaders = FetchRequest.extend({ 775 | contentType: 'application/json', 776 | }); 777 | 778 | const service = RequestWithHeaders.create(); 779 | return service.post('/test', { 780 | data: { 781 | foo: 'bar', 782 | }, 783 | }); 784 | }); 785 | 786 | test('it JSON encodes JSON:API request data automatically per contentType', function (assert) { 787 | this.server.post('/test', ({ requestBody }) => { 788 | const { foo } = JSON.parse(requestBody); 789 | assert.strictEqual(foo, 'bar'); 790 | return jsonResponse(); 791 | }); 792 | 793 | const RequestWithHeaders = FetchRequest.extend({ 794 | contentType: 'application/vnd.api+json', 795 | }); 796 | 797 | const service = RequestWithHeaders.create(); 798 | return service.post('/test', { 799 | data: { 800 | foo: 'bar', 801 | }, 802 | }); 803 | }); 804 | 805 | test('it JSON encodes JSON request data automatically per Content-Type header', function (assert) { 806 | this.server.post('/test', ({ requestBody }) => { 807 | const { foo } = JSON.parse(requestBody); 808 | assert.strictEqual(foo, 'bar'); 809 | return jsonResponse(); 810 | }); 811 | 812 | const RequestWithHeaders = FetchRequest.extend({ 813 | headers: { 814 | 'Content-Type': 'application/json', 815 | }, 816 | }); 817 | 818 | const service = RequestWithHeaders.create(); 819 | return service.post('/test', { 820 | data: { 821 | foo: 'bar', 822 | }, 823 | }); 824 | }); 825 | 826 | test('it JSON encodes JSON:API request data automatically per Content-Type header', function (assert) { 827 | this.server.post('/test', ({ requestBody }) => { 828 | const { foo } = JSON.parse(requestBody); 829 | assert.strictEqual(foo, 'bar'); 830 | return jsonResponse(); 831 | }); 832 | 833 | const RequestWithHeaders = FetchRequest.extend({ 834 | headers: { 835 | 'Content-Type': 'application/vnd.api+json', 836 | }, 837 | }); 838 | 839 | const service = RequestWithHeaders.create(); 840 | return service.post('/test', { 841 | data: { 842 | foo: 'bar', 843 | }, 844 | }); 845 | }); 846 | 847 | test('it does not JSON encode query parameters when JSON:API headers are present', function (assert) { 848 | this.server.get('/test', ({ queryParams }) => { 849 | const { foo } = queryParams; 850 | assert.strictEqual(foo, 'bar'); 851 | return jsonResponse(); 852 | }); 853 | 854 | const RequestWithHeaders = FetchRequest.extend({ 855 | headers: { 856 | 'Content-Type': 'application/vnd.api+json', 857 | }, 858 | }); 859 | 860 | const service = RequestWithHeaders.create(); 861 | return service.request('/test', { 862 | data: { 863 | foo: 'bar', 864 | }, 865 | }); 866 | }); 867 | 868 | test('it JSON encodes JSON:API "extension" request data automatically', function (assert) { 869 | this.server.post('/test', ({ requestBody }) => { 870 | const { foo } = JSON.parse(requestBody); 871 | assert.strictEqual(foo, 'bar'); 872 | return jsonResponse(); 873 | }); 874 | 875 | const RequestWithHeaders = FetchRequest.extend({ 876 | headers: { 877 | 'Content-Type': 'application/vnd.api+json; ext="ext1,ext2"', 878 | }, 879 | }); 880 | 881 | const service = RequestWithHeaders.create(); 882 | return service.post('/test', { 883 | data: { 884 | foo: 'bar', 885 | }, 886 | }); 887 | }); 888 | 889 | module('URL building', function () { 890 | class NamespaceLeadingSlash extends FetchRequest { 891 | static get slashType() { 892 | return 'leading slash'; 893 | } 894 | 895 | get namespace() { 896 | return '/bar'; 897 | } 898 | } 899 | 900 | class NamespaceTrailingSlash extends FetchRequest { 901 | static get slashType() { 902 | return 'trailing slash'; 903 | } 904 | 905 | get namespace() { 906 | return 'bar/'; 907 | } 908 | } 909 | 910 | class NamespaceTwoSlash extends FetchRequest { 911 | static get slashType() { 912 | return 'leading and trailing slash'; 913 | } 914 | 915 | get namespace() { 916 | return '/bar/'; 917 | } 918 | } 919 | 920 | class NamespaceNoSlash extends FetchRequest { 921 | static get slashType() { 922 | return 'no slashes'; 923 | } 924 | 925 | get namespace() { 926 | return 'bar'; 927 | } 928 | } 929 | 930 | const hosts = [ 931 | { hostType: 'trailing slash', host: 'http://foo.com/' }, 932 | { hostType: 'no trailing slash', host: 'http://foo.com' }, 933 | ]; 934 | 935 | [ 936 | NamespaceLeadingSlash, 937 | NamespaceTrailingSlash, 938 | NamespaceTwoSlash, 939 | NamespaceNoSlash, 940 | ].forEach((Klass) => { 941 | const req = Klass.create(); 942 | 943 | hosts.forEach((exampleHost) => { 944 | const { host } = exampleHost; 945 | 946 | test(`correctly handles ${Klass.slashType} when the host has ${exampleHost.hostType}`, function (assert) { 947 | ['/baz', 'baz'].forEach((segment) => { 948 | assert.strictEqual( 949 | req._buildURL(segment, { host }), 950 | 'http://foo.com/bar/baz', 951 | ); 952 | }); 953 | ['/baz/', 'baz/'].forEach((segment) => { 954 | assert.strictEqual( 955 | req._buildURL(segment, { host }), 956 | 'http://foo.com/bar/baz/', 957 | ); 958 | }); 959 | }); 960 | }); 961 | }); 962 | 963 | test('correctly handles a host provided on the request options', function (assert) { 964 | const req = FetchRequest.create(); 965 | assert.strictEqual( 966 | req._buildURL('/baz', { host: 'http://foo.com' }), 967 | 'http://foo.com/baz', 968 | ); 969 | }); 970 | 971 | test('correctly handles no namespace or host', function (assert) { 972 | const req = FetchRequest.create(); 973 | assert.strictEqual(req._buildURL('/baz'), '/baz'); 974 | assert.strictEqual(req._buildURL('baz'), 'baz'); 975 | }); 976 | 977 | test('does not build the URL if the namespace is already present', function (assert) { 978 | class RequestWithNamespace extends FetchRequest { 979 | get namespace() { 980 | return 'api'; 981 | } 982 | } 983 | 984 | const req = RequestWithNamespace.create(); 985 | assert.strictEqual( 986 | req._buildURL('/api/post'), 987 | '/api/post', 988 | 'URL provided with leading slash', 989 | ); 990 | assert.strictEqual( 991 | req._buildURL('api/post'), 992 | 'api/post', 993 | 'URL provided without leading slash', 994 | ); 995 | }); 996 | 997 | test('correctly handles a URL with leading part similar to the namespace', function (assert) { 998 | class RequestWithNamespace extends FetchRequest { 999 | get namespace() { 1000 | return 'admin'; 1001 | } 1002 | } 1003 | 1004 | const req = RequestWithNamespace.create(); 1005 | assert.strictEqual( 1006 | req._buildURL('/admin_users/post'), 1007 | 'admin/admin_users/post', 1008 | ); 1009 | }); 1010 | 1011 | module('building relative URLs', function () { 1012 | test('works with a relative namespace with no trailing slash', function (assert) { 1013 | class RelativeNamespace extends FetchRequest { 1014 | get namespace() { 1015 | return 'api/v1'; 1016 | } 1017 | } 1018 | 1019 | const req = RelativeNamespace.create(); 1020 | assert.strictEqual(req._buildURL('foobar'), 'api/v1/foobar'); 1021 | }); 1022 | 1023 | test('works with a relative namespace with a trailing slash', function (assert) { 1024 | class RelativeNamespace extends FetchRequest { 1025 | get namespace() { 1026 | return 'api/v1/'; 1027 | } 1028 | } 1029 | 1030 | const req = RelativeNamespace.create(); 1031 | assert.strictEqual(req._buildURL('foobar'), 'api/v1/foobar'); 1032 | }); 1033 | }); 1034 | 1035 | module('building a URL with a host', function () { 1036 | test('correctly handles a host without a namespace', function (assert) { 1037 | class HostWithoutNamespace extends FetchRequest { 1038 | get host() { 1039 | return 'http://foo.com'; 1040 | } 1041 | } 1042 | 1043 | const req = HostWithoutNamespace.create(); 1044 | assert.strictEqual(req._buildURL('baz'), 'http://foo.com/baz'); 1045 | }); 1046 | 1047 | test('does not build the URL if the host is already present', function (assert) { 1048 | class RequestWithHost extends FetchRequest { 1049 | get host() { 1050 | return 'https://foo.com'; 1051 | } 1052 | } 1053 | 1054 | const req = RequestWithHost.create(); 1055 | assert.strictEqual( 1056 | req._buildURL('https://foo.com/posts'), 1057 | 'https://foo.com/posts', 1058 | ); 1059 | }); 1060 | }); 1061 | }); 1062 | 1063 | module('error handlers', function () { 1064 | // test('handles a TimeoutError correctly', function(assert) { 1065 | // this.server.get('/posts', jsonFactory(200), 2); 1066 | // const service = FetchRequest.create(); 1067 | // return service 1068 | // .request('/posts', { timeout: 1 }) 1069 | // .then(function() { 1070 | // throw new Error('success handler should not be called'); 1071 | // }) 1072 | // .catch(function(reason) { 1073 | // assert.ok(isTimeoutError(reason)); 1074 | // assert.strictEqual(reason.payload, null); 1075 | // assert.strictEqual(reason.status, -1); 1076 | // }); 1077 | // }); 1078 | 1079 | function errorHandlerTest(status, errorClass) { 1080 | test(`handles a ${status} response correctly and preserves the payload`, function (assert) { 1081 | this.server.get( 1082 | '/posts', 1083 | jsonFactory(status, { 1084 | errors: [{ id: 1, message: 'error description' }], 1085 | }), 1086 | ); 1087 | const service = FetchRequest.create(); 1088 | return service 1089 | .request('/posts') 1090 | .then(function () { 1091 | throw new Error('success handler should not be called'); 1092 | }) 1093 | .catch(function (reason) { 1094 | assert.ok(reason instanceof errorClass); 1095 | assert.notStrictEqual(reason.payload, undefined); 1096 | assert.strictEqual(reason.status, status); 1097 | 1098 | const { errors } = reason.payload; 1099 | 1100 | assert.ok(errors && typeOf(errors) === 'array'); 1101 | assert.strictEqual(errors[0].id, 1); 1102 | assert.strictEqual(errors[0].message, 'error description'); 1103 | }); 1104 | }); 1105 | } 1106 | 1107 | errorHandlerTest(401, UnauthorizedError); 1108 | errorHandlerTest(403, ForbiddenError); 1109 | errorHandlerTest(409, ConflictError); 1110 | errorHandlerTest(410, GoneError); 1111 | errorHandlerTest(422, InvalidError); 1112 | errorHandlerTest(400, BadRequestError); 1113 | errorHandlerTest(500, ServerError); 1114 | errorHandlerTest(502, ServerError); 1115 | errorHandlerTest(510, ServerError); 1116 | 1117 | test(`handles response with malformed json`, function (assert) { 1118 | this.server.get('/posts', () => [ 1119 | 200, 1120 | { 'Content-Type': 'application/json' }, 1121 | 'foobar', 1122 | ]); 1123 | const service = FetchRequest.create(); 1124 | return service 1125 | .request('/posts') 1126 | .then(function () { 1127 | throw new Error('success handler should not be called'); 1128 | }) 1129 | .catch(function (reason) { 1130 | assert.notStrictEqual(reason.payload, undefined); 1131 | assert.ok(reason.message.includes('Unexpected token')); 1132 | assert.ok(reason.message.includes('GET')); 1133 | assert.ok(reason.message.includes('/posts')); 1134 | assert.strictEqual(reason.status, 200); 1135 | }); 1136 | }); 1137 | }); 1138 | }); 1139 | --------------------------------------------------------------------------------