├── .npmignore ├── .gitignore ├── .babelrc ├── src ├── request_interceptor.js ├── index.js ├── verbs.js ├── lib │ └── utils.js ├── fetch_response.js └── fetch_request.js ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── ci.yml ├── rollup.config.mjs ├── LICENSE ├── package.json ├── __tests__ ├── verbs.js ├── request_interceptor.js ├── fetch_response.js └── fetch_request.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | rollup.config.js 2 | __tests__/ 3 | .github/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | 5 | .vscode -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": ["@babel/plugin-transform-modules-commonjs"] 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /src/request_interceptor.js: -------------------------------------------------------------------------------- 1 | export class RequestInterceptor { 2 | static register (interceptor) { 3 | this.interceptor = interceptor 4 | } 5 | 6 | static get () { 7 | return this.interceptor 8 | } 9 | 10 | static reset () { 11 | this.interceptor = undefined 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | ### Steps to reproduce 2 | 3 | 4 | ```js 5 | # Your reproduction script goes here 6 | ``` 7 | 8 | ### Expected behavior 9 | 10 | 11 | ### Actual behavior 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { FetchRequest } from './fetch_request' 2 | import { FetchResponse } from './fetch_response' 3 | import { RequestInterceptor } from './request_interceptor' 4 | import { get, post, put, patch, destroy } from './verbs' 5 | 6 | export { FetchRequest, FetchResponse, RequestInterceptor, get, post, put, patch, destroy } 7 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve" 2 | import terser from "@rollup/plugin-terser" 3 | import pkg from "./package.json" assert { type: 'json' }; 4 | 5 | export default { 6 | input: pkg.module, 7 | output: { 8 | file: pkg.main, 9 | format: "es", 10 | inlineDynamicImports: true 11 | }, 12 | plugins: [ 13 | resolve(), 14 | terser({ 15 | mangle: false, 16 | compress: false, 17 | format: { 18 | beautify: true, 19 | indent_level: 2 20 | } 21 | }) 22 | ] 23 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | qa: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: '22' 13 | - uses: actions/cache@v4 14 | with: 15 | path: node_modules 16 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 17 | - run: yarn install 18 | - name: Lint 19 | run: yarn lint 20 | - name: Test 21 | run: yarn test 22 | -------------------------------------------------------------------------------- /src/verbs.js: -------------------------------------------------------------------------------- 1 | import { FetchRequest } from './fetch_request' 2 | 3 | async function get (url, options) { 4 | const request = new FetchRequest('get', url, options) 5 | return request.perform() 6 | } 7 | 8 | async function post (url, options) { 9 | const request = new FetchRequest('post', url, options) 10 | return request.perform() 11 | } 12 | 13 | async function put (url, options) { 14 | const request = new FetchRequest('put', url, options) 15 | return request.perform() 16 | } 17 | 18 | async function patch (url, options) { 19 | const request = new FetchRequest('patch', url, options) 20 | return request.perform() 21 | } 22 | 23 | async function destroy (url, options) { 24 | const request = new FetchRequest('delete', url, options) 25 | return request.perform() 26 | } 27 | 28 | export { get, post, put, patch, destroy } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 37signals, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rails/request.js", 3 | "version": "0.0.12", 4 | "description": "A tiny Fetch API wrapper that allows you to make http requests without need to handle to send the CSRF Token on every request", 5 | "main": "./dist/requestjs.js", 6 | "module": "./src/index.js", 7 | "repository": "https://github.com/rails/request.js", 8 | "author": "Marcelo Lauxen ", 9 | "license": "MIT", 10 | "private": false, 11 | "keywords": [ 12 | "rails", 13 | "fetch", 14 | "browser", 15 | "fetch api" 16 | ], 17 | "bugs": { 18 | "url": "https://github.com/rails/request.js" 19 | }, 20 | "scripts": { 21 | "lint": "standard src", 22 | "build": "rollup -c", 23 | "test": "jest" 24 | }, 25 | "devDependencies": { 26 | "@babel/core": "^7.26.10", 27 | "@babel/plugin-transform-modules-commonjs": "^7.26.3", 28 | "@rollup/plugin-node-resolve": "^16.0.1", 29 | "@rollup/plugin-terser": "^0.4.4", 30 | "babel-jest": "^29.7.0", 31 | "isomorphic-fetch": "^3.0.0", 32 | "jest": "^29.7.0", 33 | "jest-environment-jsdom": "^29.7.0", 34 | "rollup": "^4.40.1", 35 | "standard": "^17.1.2" 36 | }, 37 | "dependencies": {} 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export function getCookie (name) { 2 | const cookies = document.cookie ? document.cookie.split('; ') : [] 3 | const prefix = `${encodeURIComponent(name)}=` 4 | const cookie = cookies.find(cookie => cookie.startsWith(prefix)) 5 | 6 | if (cookie) { 7 | const value = cookie.split('=').slice(1).join('=') 8 | 9 | if (value) { 10 | return decodeURIComponent(value) 11 | } 12 | } 13 | } 14 | 15 | export function compact (object) { 16 | const result = {} 17 | 18 | for (const key in object) { 19 | const value = object[key] 20 | if (value !== undefined) { 21 | result[key] = value 22 | } 23 | } 24 | 25 | return result 26 | } 27 | 28 | export function metaContent (name) { 29 | const element = document.head.querySelector(`meta[name="${name}"]`) 30 | return element && element.content 31 | } 32 | 33 | export function stringEntriesFromFormData (formData) { 34 | return [...formData].reduce((entries, [name, value]) => { 35 | return entries.concat(typeof value === 'string' ? [[name, value]] : []) 36 | }, []) 37 | } 38 | 39 | export function mergeEntries (searchParams, entries) { 40 | for (const [name, value] of entries) { 41 | if (value instanceof window.File) continue 42 | 43 | if (searchParams.has(name) && !name.includes('[]')) { 44 | searchParams.delete(name) 45 | searchParams.set(name, value) 46 | } else { 47 | searchParams.append(name, value) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /__tests__/verbs.js: -------------------------------------------------------------------------------- 1 | import { get, post, put, patch, destroy } from '../src/verbs' 2 | 3 | import { FetchRequest } from '../src/fetch_request' 4 | jest.mock('../src/fetch_request', () => ({ 5 | // __esModule: true, 6 | FetchRequest: jest.fn().mockImplementation(() => { 7 | return { 8 | perform: jest.fn() 9 | } 10 | }) 11 | })) 12 | 13 | beforeEach(() => { 14 | // Clear all instances and calls to constructor and all methods: 15 | FetchRequest.mockClear(); 16 | }); 17 | 18 | const mockRequestOptions = { with: "options" } 19 | 20 | test('"get" verb correctly creates FetchRequest and performs it', () => { 21 | get("myurl", mockRequestOptions) 22 | expect(FetchRequest).toHaveBeenCalledTimes(1) 23 | expect(FetchRequest).toHaveBeenCalledWith('get', 'myurl', mockRequestOptions) 24 | expect(FetchRequest.mock.results[0].value.perform).toHaveBeenCalledTimes(1) 25 | }) 26 | 27 | test('"post" verb correctly creates FetchRequest and performs it', () => { 28 | post("myurl", mockRequestOptions) 29 | expect(FetchRequest).toHaveBeenCalledTimes(1) 30 | expect(FetchRequest).toHaveBeenCalledWith('post', 'myurl', mockRequestOptions) 31 | expect(FetchRequest.mock.results[0].value.perform).toHaveBeenCalledTimes(1) 32 | }) 33 | 34 | test('"put" verb correctly creates FetchRequest and performs it', () => { 35 | put("myurl", mockRequestOptions) 36 | expect(FetchRequest).toHaveBeenCalledTimes(1) 37 | expect(FetchRequest).toHaveBeenCalledWith('put', 'myurl', mockRequestOptions) 38 | expect(FetchRequest.mock.results[0].value.perform).toHaveBeenCalledTimes(1) 39 | }) 40 | 41 | test('"patch" verb correctly creates FetchRequest and performs it', () => { 42 | patch("myurl", mockRequestOptions) 43 | expect(FetchRequest).toHaveBeenCalledTimes(1) 44 | expect(FetchRequest).toHaveBeenCalledWith('patch', 'myurl', mockRequestOptions) 45 | expect(FetchRequest.mock.results[0].value.perform).toHaveBeenCalledTimes(1) 46 | }) 47 | 48 | test('"destroy" verb correctly creates FetchRequest and performs it', () => { 49 | destroy("myurl", mockRequestOptions) 50 | expect(FetchRequest).toHaveBeenCalledTimes(1) 51 | expect(FetchRequest).toHaveBeenCalledWith('delete', 'myurl', mockRequestOptions) 52 | expect(FetchRequest.mock.results[0].value.perform).toHaveBeenCalledTimes(1) 53 | }) 54 | -------------------------------------------------------------------------------- /__tests__/request_interceptor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import 'isomorphic-fetch' 5 | import { RequestInterceptor } from '../src/request_interceptor' 6 | import { FetchRequest } from '../src/fetch_request' 7 | 8 | beforeEach(() => { 9 | window.fetch = jest.fn().mockResolvedValue(new Response("success!", { status: 200, body: "done" })) 10 | }) 11 | 12 | test('request intercepter is executed', async () => { 13 | const mockInterceptor = jest.fn(() => { 14 | return Promise.resolve("hi!") 15 | }) 16 | RequestInterceptor.register(mockInterceptor) 17 | 18 | const testRequest = new FetchRequest("get", "localhost") 19 | await testRequest.perform() 20 | 21 | expect(RequestInterceptor.get()).toBeDefined() 22 | expect(mockInterceptor).toHaveBeenCalledTimes(1) 23 | expect(window.fetch).toHaveBeenCalledTimes(1) 24 | }) 25 | 26 | test('request interceptors overwrite each other', async () => { 27 | const mockInterceptorOne = jest.fn().mockResolvedValue() 28 | const mockInterceptorTwo = jest.fn().mockResolvedValue() 29 | RequestInterceptor.register(mockInterceptorOne) 30 | RequestInterceptor.register(mockInterceptorTwo) 31 | 32 | const testRequest = new FetchRequest("get", "localhost") 33 | await testRequest.perform() 34 | 35 | expect(RequestInterceptor.get()).toBeDefined() 36 | expect(mockInterceptorOne).toHaveBeenCalledTimes(0) 37 | expect(mockInterceptorTwo).toHaveBeenCalledTimes(1) 38 | }) 39 | 40 | test('request executes even when interceptor rejects', async () => { 41 | console.error = jest.fn() 42 | const mockInterceptor = jest.fn().mockRejectedValue() 43 | RequestInterceptor.register(mockInterceptor) 44 | 45 | const testRequest = new FetchRequest("get", "localhost") 46 | await testRequest.perform() 47 | 48 | expect(RequestInterceptor.get()).toBeDefined() 49 | expect(mockInterceptor).toHaveBeenCalledTimes(1) 50 | expect(console.error).toHaveBeenCalledTimes(1) 51 | expect(window.fetch).toHaveBeenCalledTimes(1) 52 | }) 53 | 54 | test('resetting unregisters interceptor', async () => { 55 | const mockInterceptor = jest.fn().mockResolvedValue() 56 | RequestInterceptor.register(mockInterceptor) 57 | expect(RequestInterceptor.get()).toBeDefined() 58 | RequestInterceptor.reset() 59 | expect(RequestInterceptor.get()).toBeUndefined() 60 | 61 | const testRequest = new FetchRequest("get", "localhost") 62 | await testRequest.perform() 63 | 64 | expect(mockInterceptor).toHaveBeenCalledTimes(0) 65 | expect(window.fetch).toHaveBeenCalledTimes(1) 66 | }) 67 | -------------------------------------------------------------------------------- /src/fetch_response.js: -------------------------------------------------------------------------------- 1 | export class FetchResponse { 2 | constructor (response) { 3 | this.response = response 4 | } 5 | 6 | get statusCode () { 7 | return this.response.status 8 | } 9 | 10 | get redirected () { 11 | return this.response.redirected 12 | } 13 | 14 | get ok () { 15 | return this.response.ok 16 | } 17 | 18 | get unauthenticated () { 19 | return this.statusCode === 401 20 | } 21 | 22 | get unprocessableEntity () { 23 | return this.statusCode === 422 24 | } 25 | 26 | get authenticationURL () { 27 | return this.response.headers.get('WWW-Authenticate') 28 | } 29 | 30 | get contentType () { 31 | const contentType = this.response.headers.get('Content-Type') || '' 32 | 33 | return contentType.replace(/;.*$/, '') 34 | } 35 | 36 | get headers () { 37 | return this.response.headers 38 | } 39 | 40 | get html () { 41 | if (this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)) { 42 | return this.text 43 | } 44 | 45 | return Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`)) 46 | } 47 | 48 | get json () { 49 | if (this.contentType.match(/^application\/.*json$/)) { 50 | return this.responseJson || (this.responseJson = this.response.json()) 51 | } 52 | 53 | return Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`)) 54 | } 55 | 56 | get text () { 57 | return this.responseText || (this.responseText = this.response.text()) 58 | } 59 | 60 | get isTurboStream () { 61 | return this.contentType.match(/^text\/vnd\.turbo-stream\.html/) 62 | } 63 | 64 | get isScript () { 65 | return this.contentType.match(/\b(?:java|ecma)script\b/) 66 | } 67 | 68 | async renderTurboStream () { 69 | if (this.isTurboStream) { 70 | if (window.Turbo) { 71 | await window.Turbo.renderStreamMessage(await this.text) 72 | } else { 73 | console.warn('You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js') 74 | } 75 | } else { 76 | return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`)) 77 | } 78 | } 79 | 80 | async activeScript () { 81 | if (this.isScript) { 82 | const script = document.createElement('script') 83 | const metaTag = document.querySelector('meta[name=csp-nonce]') 84 | if (metaTag) { 85 | const nonce = metaTag.nonce === '' ? metaTag.content : metaTag.nonce 86 | if (nonce) { script.setAttribute('nonce', nonce) } 87 | } 88 | script.innerHTML = await this.text 89 | document.body.appendChild(script) 90 | } else { 91 | return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`)) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/fetch_request.js: -------------------------------------------------------------------------------- 1 | import { FetchResponse } from './fetch_response' 2 | import { RequestInterceptor } from './request_interceptor' 3 | import { getCookie, compact, metaContent, stringEntriesFromFormData, mergeEntries } from './lib/utils' 4 | 5 | export class FetchRequest { 6 | constructor (method, url, options = {}) { 7 | this.method = method 8 | this.options = options 9 | this.originalUrl = url.toString() 10 | } 11 | 12 | async perform () { 13 | try { 14 | const requestInterceptor = RequestInterceptor.get() 15 | if (requestInterceptor) { 16 | await requestInterceptor(this) 17 | } 18 | } catch (error) { 19 | console.error(error) 20 | } 21 | 22 | const fetch = window.Turbo ? window.Turbo.fetch : window.fetch 23 | const response = new FetchResponse(await fetch(this.url, this.fetchOptions)) 24 | 25 | if (response.unauthenticated && response.authenticationURL) { 26 | return Promise.reject(window.location.href = response.authenticationURL) 27 | } 28 | 29 | if (response.isScript) { 30 | await response.activeScript() 31 | } 32 | 33 | const responseStatusIsTurboStreamable = response.ok || response.unprocessableEntity 34 | 35 | if (responseStatusIsTurboStreamable && response.isTurboStream) { 36 | await response.renderTurboStream() 37 | } 38 | 39 | return response 40 | } 41 | 42 | addHeader (key, value) { 43 | const headers = this.additionalHeaders 44 | headers[key] = value 45 | this.options.headers = headers 46 | } 47 | 48 | sameHostname () { 49 | if (!this.originalUrl.startsWith('http:') && !this.originalUrl.startsWith('https:')) { 50 | return true 51 | } 52 | 53 | try { 54 | return new URL(this.originalUrl).hostname === window.location.hostname 55 | } catch (_) { 56 | return true 57 | } 58 | } 59 | 60 | get fetchOptions () { 61 | return { 62 | method: this.method.toUpperCase(), 63 | headers: this.headers, 64 | body: this.formattedBody, 65 | signal: this.signal, 66 | credentials: this.credentials, 67 | redirect: this.redirect, 68 | keepalive: this.keepalive 69 | } 70 | } 71 | 72 | get headers () { 73 | const baseHeaders = { 74 | 'X-Requested-With': 'XMLHttpRequest', 75 | 'Content-Type': this.contentType, 76 | Accept: this.accept 77 | } 78 | 79 | if (this.sameHostname()) { 80 | baseHeaders['X-CSRF-Token'] = this.csrfToken 81 | } 82 | 83 | return compact( 84 | Object.assign(baseHeaders, this.additionalHeaders) 85 | ) 86 | } 87 | 88 | get csrfToken () { 89 | return getCookie(metaContent('csrf-param')) || metaContent('csrf-token') 90 | } 91 | 92 | get contentType () { 93 | if (this.options.contentType) { 94 | return this.options.contentType 95 | } else if (this.body == null || this.body instanceof window.FormData) { 96 | return undefined 97 | } else if (this.body instanceof window.File) { 98 | return this.body.type 99 | } 100 | 101 | return 'application/json' 102 | } 103 | 104 | get accept () { 105 | switch (this.responseKind) { 106 | case 'html': 107 | return 'text/html, application/xhtml+xml' 108 | case 'turbo-stream': 109 | return 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml' 110 | case 'json': 111 | return 'application/json, application/vnd.api+json' 112 | case 'script': 113 | return 'text/javascript, application/javascript' 114 | default: 115 | return '*/*' 116 | } 117 | } 118 | 119 | get body () { 120 | return this.options.body 121 | } 122 | 123 | get query () { 124 | const originalQuery = (this.originalUrl.split('?')[1] || '').split('#')[0] 125 | const params = new URLSearchParams(originalQuery) 126 | 127 | let requestQuery = this.options.query 128 | if (requestQuery instanceof window.FormData) { 129 | requestQuery = stringEntriesFromFormData(requestQuery) 130 | } else if (requestQuery instanceof window.URLSearchParams) { 131 | requestQuery = requestQuery.entries() 132 | } else { 133 | requestQuery = Object.entries(requestQuery || {}) 134 | } 135 | 136 | mergeEntries(params, requestQuery) 137 | 138 | const query = params.toString() 139 | return (query.length > 0 ? `?${query}` : '') 140 | } 141 | 142 | get url () { 143 | return (this.originalUrl.split('?')[0]).split('#')[0] + this.query 144 | } 145 | 146 | get responseKind () { 147 | return this.options.responseKind || 'html' 148 | } 149 | 150 | get signal () { 151 | return this.options.signal 152 | } 153 | 154 | get redirect () { 155 | return this.options.redirect || 'follow' 156 | } 157 | 158 | get credentials () { 159 | return this.options.credentials || 'same-origin' 160 | } 161 | 162 | get keepalive () { 163 | return this.options.keepalive || false 164 | } 165 | 166 | get additionalHeaders () { 167 | return this.options.headers || {} 168 | } 169 | 170 | get formattedBody () { 171 | const bodyIsAString = Object.prototype.toString.call(this.body) === '[object String]' 172 | const contentTypeIsJson = this.headers['Content-Type'] === 'application/json' 173 | 174 | if (contentTypeIsJson && !bodyIsAString) { 175 | return JSON.stringify(this.body) 176 | } 177 | 178 | return this.body 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rails Request.JS 2 | 3 | Rails Request.JS encapsulates the logic to send by default some headers that are required by rails applications like the `X-CSRF-Token`. 4 | 5 | # Install 6 | 7 | ## Asset Pipeline 8 | 9 | Install the [requestjs-rails](https://github.com/rails/requestjs-rails) gem and follow the step described there. 10 | 11 | ## Webpacker/Esbuild 12 | 13 | ### npm 14 | ``` 15 | npm i @rails/request.js 16 | ``` 17 | ### yarn 18 | ```shell 19 | yarn add @rails/request.js 20 | ``` 21 | 22 | # How to use 23 | 24 | Just import the `FetchRequest` class from the package and instantiate it passing the request `method`, `url`, `options`, then call `await request.perform()` and do what you need with the response. 25 | 26 | Example: 27 | 28 | ```js 29 | import { FetchRequest } from '@rails/request.js' 30 | 31 | .... 32 | 33 | async myMethod () { 34 | const request = new FetchRequest('post', 'localhost:3000/my_endpoint', { body: JSON.stringify({ name: 'Request.JS' }) }) 35 | const response = await request.perform() 36 | if (response.ok) { 37 | const body = await response.text 38 | // Do whatever do you want with the response body 39 | // You also are able to call `response.html` or `response.json`, be aware that if you call `response.json` and the response contentType isn't `application/json` there will be raised an error. 40 | } 41 | } 42 | ``` 43 | 44 | #### Shorthand methods 45 | 46 | Alternatively, you can use a shorthand version for the main HTTP verbs, `get`, `post`, `put`, `patch` or `destroy`. 47 | 48 | Example: 49 | 50 | ```js 51 | import { get, post, put, patch, destroy } from '@rails/request.js' 52 | 53 | ... 54 | 55 | async myMethod () { 56 | const response = await post('localhost:3000/my_endpoint', { body: JSON.stringify({ name: 'Request.JS' }) }) 57 | if (response.ok) { 58 | const body = await response.json 59 | ... 60 | } 61 | } 62 | ``` 63 | 64 | #### Request Options 65 | 66 | You can pass options to a request as the last argument. For example: 67 | 68 | ```javascript 69 | post("/my_endpoint", { 70 | body: {}, 71 | contentType: "application/json", 72 | headers: {}, 73 | query: {}, 74 | responseKind: "html" 75 | }) 76 | ``` 77 | 78 | ##### body 79 | 80 | This is the `body` for POST requests. You can pass in a Javascript object, FormData, Files, strings, etc. 81 | 82 | Request.js will automatically JSON stringify the `body` if the content type is `application/json`. 83 | 84 | ##### contentType 85 | 86 | When provided this value will be sent in the `Content-Type` header. When not provided Request.JS will send nothing when the `body` of the request is `null` or an instance of `FormData`, when the `body` is an instance of a `File` then the type of the file will be sent and `application/json` will be sent if none of the prior conditions matches. 87 | 88 | ##### headers 89 | 90 | Adds additional headers to the request. `X-CSRF-Token` and `Content-Type` are automatically included. 91 | 92 | ##### credentials 93 | 94 | Specifies the `credentials` option. Default is `same-origin`. 95 | 96 | ##### query 97 | 98 | Appends query parameters to the URL. Query params in the URL are preserved and merged with the query options. 99 | 100 | Accepts `Object`, `FormData` or `URLSearchParams`. 101 | 102 | ##### responseKind 103 | 104 | Specifies which response format will be accepted. Default is `html`. 105 | 106 | Options are `html`, `turbo-stream`, `json`, and `script`. 107 | 108 | ##### keepalive 109 | 110 | Specifies the `keepalive` option. Default is `false`. 111 | 112 | #### Turbo Streams 113 | 114 | Request.JS will automatically process Turbo Stream responses. Ensure that your Javascript sets the `window.Turbo` global variable: 115 | 116 | ```javascript 117 | import { Turbo } from "@hotwired/turbo-rails" 118 | window.Turbo = Turbo 119 | ``` 120 | 121 | Since [v7.0.0-beta.6](https://github.com/hotwired/turbo/releases/tag/v7.0.0-beta.6) Turbo sets `window.Turbo` automatically. 122 | 123 | Request.JS will also use Turbo's `fetch` to include the `X-Turbo-Request-ID` header in the request (see [#73](https://github.com/rails/request.js/issues/73)). 124 | 125 | #### Script Responses 126 | 127 | Request.JS will automatically activate script tags in the response (see [#48](https://github.com/rails/request.js/issues/48)). 128 | 129 | #### Request Interceptor 130 | 131 | To authenticate fetch requests (eg. with Bearer token) you can use request interceptor. It allows pausing request invocation for fetching token and then adding it to headers: 132 | 133 | ```javascript 134 | import { RequestInterceptor } from '@rails/request.js' 135 | // ... 136 | 137 | // Set interceptor 138 | RequestInterceptor.register(async (request) => { 139 | const token = await getSessionToken(window.app) 140 | request.addHeader('Authorization', `Bearer ${token}`) 141 | }) 142 | 143 | // Reset interceptor 144 | RequestInterceptor.reset() 145 | ``` 146 | 147 | #### Before and after hooks 148 | 149 | Wrap the request `Promise` with your own code. Just pure and simple JavaScript like this: 150 | 151 | ```javascript 152 | import { FetchRequest } from "@rails/request.js" 153 | import { navigator } from "@hotwired/turbo" 154 | 155 | function showProgressBar() { 156 | navigator.delegate.adapter.progressBar.setValue(0) 157 | navigator.delegate.adapter.progressBar.show() 158 | } 159 | 160 | function hideProgressBar() { 161 | navigator.delegate.adapter.progressBar.setValue(1) 162 | navigator.delegate.adapter.progressBar.hide() 163 | } 164 | 165 | export function withProgress(request) { 166 | showProgressBar() 167 | 168 | return request.then((response) => { 169 | hideProgressBar() 170 | return response 171 | }) 172 | } 173 | 174 | export function get(url, options) { 175 | const request = new FetchRequest("get", url, options) 176 | return withProgress(request.perform()) 177 | } 178 | ``` 179 | 180 | ## Response 181 | 182 | ### statusCode 183 | 184 | Returns the response status. 185 | 186 | ### ok 187 | 188 | Returns true if the response was successful. 189 | 190 | ### unauthenticated 191 | 192 | Returns true if the response has a `401` status code. 193 | 194 | ### authenticationURL 195 | 196 | Returns the value contained in the `WWW-Authenticate` header. 197 | 198 | ### contentType 199 | 200 | Returns the response content-type. 201 | 202 | ### html 203 | 204 | Returns the html body, if the content type of the response isn't `html` then will be returned a rejected promise. 205 | 206 | ### json 207 | 208 | Returns the json body, if the content type of the response isn't `json` then will be returned a rejected promise. 209 | 210 | ### headers 211 | 212 | Returns the response headers. 213 | 214 | # Known Issues 215 | 216 | `FetchRequest` sets a `"X-Requested-With": "XmlHttpRequest"` header. If you have not upgraded to Turbo and still use `Turbolinks` in your Gemfile, this means 217 | you will not be able to check if the request was redirected. 218 | 219 | ```js 220 | const request = new FetchRequest('post', 'localhost:3000/my_endpoint', { body: JSON.stringify({ name: 'Request.JS' }) }) 221 | const response = await request.perform() 222 | response.redirected // => will always be false. 223 | ``` 224 | 225 | # License 226 | 227 | Rails Request.JS is released under the [MIT License](LICENSE). 228 | 229 | © 37signals, LLC. 230 | -------------------------------------------------------------------------------- /__tests__/fetch_response.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import 'isomorphic-fetch' 5 | import { FetchResponse } from '../src/fetch_response' 6 | 7 | test('default contentType', async () => { 8 | const mockResponse = new Response(null, { status: 200 }) 9 | const testResponse = new FetchResponse(mockResponse) 10 | 11 | expect(testResponse.contentType).toEqual("") 12 | }) 13 | 14 | describe('body accessors', () => { 15 | describe('text', () => { 16 | test('works multiple times', async () => { 17 | const mockResponse = new Response("Mock", { status: 200, headers: new Headers({'Content-Type': 'text/plain'}) }) 18 | const testResponse = new FetchResponse(mockResponse) 19 | 20 | expect(await testResponse.text).toBe("Mock") 21 | expect(await testResponse.text).toBe("Mock") 22 | }) 23 | test('work regardless of content-type', async () => { 24 | const mockResponse = new Response("Mock", { status: 200, headers: new Headers({'Content-Type': 'not/text'}) }) 25 | const testResponse = new FetchResponse(mockResponse) 26 | 27 | expect(await testResponse.text).toBe("Mock") 28 | }) 29 | }) 30 | describe('html', () => { 31 | test('works multiple times', async () => { 32 | const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'application/html'}) }) 33 | const testResponse = new FetchResponse(mockResponse) 34 | 35 | expect(await testResponse.html).toBe("

hi

") 36 | expect(await testResponse.html).toBe("

hi

") 37 | }) 38 | test('rejects on invalid content-type', async () => { 39 | const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'text/plain'}) }) 40 | const testResponse = new FetchResponse(mockResponse) 41 | 42 | expect(testResponse.html).rejects.toBeInstanceOf(Error) 43 | }) 44 | }) 45 | describe('json', () => { 46 | test('works multiple times', async () => { 47 | const mockResponse = new Response(JSON.stringify({ json: 'body' }), { status: 200, headers: new Headers({'Content-Type': 'application/json'}) }) 48 | const testResponse = new FetchResponse(mockResponse) 49 | 50 | // works mutliple times 51 | expect({ json: 'body' }).toStrictEqual(await testResponse.json) 52 | expect({ json: 'body' }).toStrictEqual(await testResponse.json) 53 | }) 54 | test('rejects on invalid content-type', async () => { 55 | const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'text/json'}) }) 56 | const testResponse = new FetchResponse(mockResponse) 57 | 58 | expect(testResponse.json).rejects.toBeInstanceOf(Error) 59 | }) 60 | }) 61 | describe('application/vnd.api+json', () => { 62 | test('works multiple times', async () => { 63 | const mockResponse = new Response(JSON.stringify({ json: 'body' }), { status: 200, headers: new Headers({'Content-Type': 'application/vnd.api+json'}) }) 64 | const testResponse = new FetchResponse(mockResponse) 65 | 66 | expect({ json: 'body' }).toStrictEqual(await testResponse.json) 67 | expect({ json: 'body' }).toStrictEqual(await testResponse.json) 68 | }) 69 | test('rejects on invalid content-type', async () => { 70 | const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'application/plain'}) }) 71 | const testResponse = new FetchResponse(mockResponse) 72 | 73 | expect(testResponse.json).rejects.toBeInstanceOf(Error) 74 | }) 75 | }) 76 | describe('turbostream', () => { 77 | const mockTurboStreamMessage = ` 78 | ` 81 | 82 | test('warns if Turbo is not registered', async () => { 83 | const mockResponse = new Response(mockTurboStreamMessage, { status: 200, headers: new Headers({'Content-Type': 'text/vnd.turbo-stream.html'}) }) 84 | const testResponse = new FetchResponse(mockResponse) 85 | const warningSpy = jest.spyOn(console, 'warn').mockImplementation() 86 | 87 | await testResponse.renderTurboStream() 88 | 89 | expect(warningSpy).toBeCalled() 90 | }) 91 | test('calls turbo', async () => { 92 | const mockResponse = new Response(mockTurboStreamMessage, { status: 200, headers: new Headers({'Content-Type': 'text/vnd.turbo-stream.html'}) }) 93 | const testResponse = new FetchResponse(mockResponse) 94 | window.Turbo = { renderStreamMessage: jest.fn() } 95 | 96 | await testResponse.renderTurboStream() 97 | expect(window.Turbo.renderStreamMessage).toHaveBeenCalledTimes(1) 98 | }) 99 | test('rejects on invalid content-type', async () => { 100 | const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'text/plain'}) }) 101 | const testResponse = new FetchResponse(mockResponse) 102 | 103 | expect(testResponse.renderTurboStream()).rejects.toBeInstanceOf(Error) 104 | }) 105 | }) 106 | describe('script', () => { 107 | test('rejects on invalid content-type', async () => { 108 | const mockResponse = new Response("", { status: 200, headers: new Headers({'Content-Type': 'text/plain'}) }) 109 | const testResponse = new FetchResponse(mockResponse) 110 | 111 | expect(testResponse.activeScript()).rejects.toBeInstanceOf(Error) 112 | }) 113 | }) 114 | }) 115 | 116 | describe('fetch response helpers', () => { 117 | test('forwards headers correctly', () => { 118 | const mockHeaders = new Headers({'Content-Type': 'text/plain'}) 119 | const mockResponse = new Response(null, { status: 200, headers: mockHeaders }) 120 | const testResponse = new FetchResponse(mockResponse) 121 | 122 | expect(testResponse.headers).toStrictEqual(mockHeaders) 123 | }) 124 | test('content-type access the headers correctly', () => { 125 | const mockHeaders = new Headers({'Content-Type': 'text/plain'}) 126 | const mockResponse = new Response(null, { status: 200, headers: mockHeaders }) 127 | const testResponse = new FetchResponse(mockResponse) 128 | 129 | expect(testResponse.contentType).toBe('text/plain') 130 | }) 131 | test('content-type cuts after semicolon', () => { 132 | const mockHeaders = new Headers({'Content-Type': 'application/json; charset=exotic'}) 133 | const mockResponse = new Response(null, { status: 200, headers: mockHeaders }) 134 | const testResponse = new FetchResponse(mockResponse) 135 | 136 | expect(testResponse.contentType).toBe('application/json') 137 | }) 138 | test('www-authentication header is accessed', () => { 139 | const mockResponse = new Response(null, { status: 401, headers: new Headers({'WWW-Authenticate': 'https://localhost/login'}) }) 140 | const testResponse = new FetchResponse(mockResponse) 141 | 142 | expect(testResponse.authenticationURL).toBe('https://localhost/login') 143 | }) 144 | }) 145 | describe('http-status helpers', () => { 146 | 147 | test('200', () => { 148 | const mockResponse = new Response(null, { status: 200 }) 149 | const testResponse = new FetchResponse(mockResponse) 150 | 151 | expect(testResponse.statusCode).toBe(200) 152 | expect(testResponse.ok).toBeTruthy() 153 | expect(testResponse.redirected).toBeFalsy() 154 | expect(testResponse.unauthenticated).toBeFalsy() 155 | expect(testResponse.unprocessableEntity).toBeFalsy() 156 | }) 157 | 158 | test('401', () => { 159 | const mockResponse = new Response(null, { status: 401 }) 160 | const testResponse = new FetchResponse(mockResponse) 161 | 162 | expect(testResponse.statusCode).toBe(401) 163 | expect(testResponse.ok).toBeFalsy() 164 | expect(testResponse.redirected).toBeFalsy() 165 | expect(testResponse.unauthenticated).toBeTruthy() 166 | expect(testResponse.unprocessableEntity).toBeFalsy() 167 | }) 168 | 169 | test('422', () => { 170 | const mockResponse = new Response(null, { status: 422 }) 171 | const testResponse = new FetchResponse(mockResponse) 172 | 173 | expect(testResponse.statusCode).toBe(422) 174 | expect(testResponse.ok).toBeFalsy() 175 | expect(testResponse.redirected).toBeFalsy() 176 | expect(testResponse.unauthenticated).toBeFalsy() 177 | expect(testResponse.unprocessableEntity).toBeTruthy() 178 | }) 179 | 180 | test('302', () => { 181 | const mockHeaders = new Headers({'Location': 'https://localhost/login'}) 182 | const mockResponse = new Response(null, { status: 302, url: 'https://localhost/login', headers: mockHeaders }) 183 | jest.spyOn(mockResponse, 'redirected', 'get').mockReturnValue(true) 184 | const testResponse = new FetchResponse(mockResponse) 185 | 186 | expect(testResponse.statusCode).toBe(302) 187 | expect(testResponse.ok).toBeFalsy() 188 | expect(testResponse.redirected).toBeTruthy() 189 | expect(testResponse.unauthenticated).toBeFalsy() 190 | expect(testResponse.unprocessableEntity).toBeFalsy() 191 | expect(testResponse.authenticationURL).toBeNull() 192 | }) 193 | }) 194 | -------------------------------------------------------------------------------- /__tests__/fetch_request.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import 'isomorphic-fetch' 5 | import { FetchRequest } from '../src/fetch_request' 6 | import { FetchResponse } from '../src/fetch_response' 7 | 8 | jest.mock('../src/lib/utils', () => { 9 | const originalModule = jest.requireActual('../src/lib/utils') 10 | return { 11 | __esModule: true, 12 | ...originalModule, 13 | getCookie: jest.fn().mockReturnValue('mock-csrf-token'), 14 | metaContent: jest.fn() 15 | } 16 | }) 17 | 18 | describe('perform', () => { 19 | test('request is performed with 200', async () => { 20 | const mockResponse = new Response("success!", { status: 200 }) 21 | window.fetch = jest.fn().mockResolvedValue(mockResponse) 22 | 23 | const testRequest = new FetchRequest("get", "localhost") 24 | const testResponse = await testRequest.perform() 25 | 26 | expect(window.fetch).toHaveBeenCalledTimes(1) 27 | expect(window.fetch).toHaveBeenCalledWith("localhost", testRequest.fetchOptions) 28 | expect(testResponse).toStrictEqual(new FetchResponse(mockResponse)) 29 | }) 30 | 31 | test('request is performed with 401', async () => { 32 | const mockResponse = new Response(undefined, { status: 401, headers: {'WWW-Authenticate': 'https://localhost/login'}}) 33 | window.fetch = jest.fn().mockResolvedValue(mockResponse) 34 | 35 | delete window.location 36 | window.location = new URL('https://www.example.com') 37 | expect(window.location.href).toBe('https://www.example.com/') 38 | 39 | const testRequest = new FetchRequest("get", "https://localhost") 40 | expect(testRequest.perform()).rejects.toBe('https://localhost/login') 41 | 42 | testRequest.perform().catch(() => { 43 | expect(window.location.href).toBe('https://localhost/login') 44 | }) 45 | }) 46 | 47 | test('turbo stream request automatically calls renderTurboStream when status is ok', async () => { 48 | const mockResponse = new Response('', { status: 200, headers: { 'Content-Type': 'text/vnd.turbo-stream.html' }}) 49 | window.fetch = jest.fn().mockResolvedValue(mockResponse) 50 | jest.spyOn(FetchResponse.prototype, "ok", "get").mockReturnValue(true) 51 | jest.spyOn(FetchResponse.prototype, "isTurboStream", "get").mockReturnValue(true) 52 | const renderSpy = jest.spyOn(FetchResponse.prototype, "renderTurboStream").mockImplementation() 53 | 54 | const testRequest = new FetchRequest("get", "localhost") 55 | await testRequest.perform() 56 | 57 | expect(renderSpy).toHaveBeenCalledTimes(1) 58 | jest.clearAllMocks(); 59 | }) 60 | 61 | test('turbo stream request automatically calls renderTurboStream when status is unprocessable entity', async () => { 62 | const mockResponse = new Response('', { status: 422, headers: { 'Content-Type': 'text/vnd.turbo-stream.html' }}) 63 | window.fetch = jest.fn().mockResolvedValue(mockResponse) 64 | jest.spyOn(FetchResponse.prototype, "ok", "get").mockReturnValue(true) 65 | jest.spyOn(FetchResponse.prototype, "isTurboStream", "get").mockReturnValue(true) 66 | const renderSpy = jest.spyOn(FetchResponse.prototype, "renderTurboStream").mockImplementation() 67 | 68 | const testRequest = new FetchRequest("get", "localhost") 69 | await testRequest.perform() 70 | 71 | expect(renderSpy).toHaveBeenCalledTimes(1) 72 | jest.clearAllMocks(); 73 | }) 74 | 75 | test('script request automatically calls activeScript', async () => { 76 | const mockResponse = new Response('', { status: 200, headers: { 'Content-Type': 'application/javascript' }}) 77 | window.fetch = jest.fn().mockResolvedValue(mockResponse) 78 | jest.spyOn(FetchResponse.prototype, "ok", "get").mockReturnValue(true) 79 | jest.spyOn(FetchResponse.prototype, "isScript", "get").mockReturnValue(true) 80 | const renderSpy = jest.spyOn(FetchResponse.prototype, "activeScript").mockImplementation() 81 | 82 | const testRequest = new FetchRequest("get", "localhost") 83 | await testRequest.perform() 84 | 85 | expect(renderSpy).toHaveBeenCalledTimes(1) 86 | jest.clearAllMocks(); 87 | }) 88 | }) 89 | 90 | test('treat method name case-insensitive', async () => { 91 | const methodNames = [ "gEt", "GeT", "get", "GET"] 92 | for (const methodName of methodNames) { 93 | const testRequest = new FetchRequest(methodName, "localhost") 94 | expect(testRequest.fetchOptions.method).toBe("GET") 95 | } 96 | }) 97 | 98 | test('casts URL to String', async () => { 99 | const testRequest = new FetchRequest("GET", new URL("http://localhost")) 100 | expect(typeof testRequest.originalUrl).toBe("string") 101 | }) 102 | 103 | describe('header handling', () => { 104 | const defaultHeaders = { 105 | 'X-Requested-With': 'XMLHttpRequest', 106 | 'X-CSRF-Token': 'mock-csrf-token', 107 | 'Accept': 'text/html, application/xhtml+xml' 108 | } 109 | describe('responseKind', () => { 110 | test('none', async () => { 111 | const defaultRequest = new FetchRequest("get", "localhost") 112 | expect(defaultRequest.fetchOptions.headers) 113 | .toStrictEqual(defaultHeaders) 114 | }) 115 | test('html', async () => { 116 | const htmlRequest = new FetchRequest("get", "localhost", { responseKind: 'html' }) 117 | expect(htmlRequest.fetchOptions.headers) 118 | .toStrictEqual(defaultHeaders) 119 | }) 120 | test('json', async () => { 121 | const jsonRequest = new FetchRequest("get", "localhost", { responseKind: 'json' }) 122 | expect(jsonRequest.fetchOptions.headers) 123 | .toStrictEqual({...defaultHeaders, 'Accept' : 'application/json, application/vnd.api+json'}) 124 | }) 125 | test('turbo-stream', async () => { 126 | const turboRequest = new FetchRequest("get", "localhost", { responseKind: 'turbo-stream' }) 127 | expect(turboRequest.fetchOptions.headers) 128 | .toStrictEqual({...defaultHeaders, 'Accept' : 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml'}) 129 | }) 130 | test('invalid', async () => { 131 | const invalidResponseKindRequest = new FetchRequest("get", "localhost", { responseKind: 'exotic' }) 132 | expect(invalidResponseKindRequest.fetchOptions.headers) 133 | .toStrictEqual({...defaultHeaders, 'Accept' : '*/*'}) 134 | }) 135 | }) 136 | 137 | describe('contentType', () => { 138 | test('is added to headers', () => { 139 | const customRequest = new FetchRequest("get", "localhost/test.json", { contentType: 'any/thing' }) 140 | expect(customRequest.fetchOptions.headers) 141 | .toStrictEqual({ ...defaultHeaders, "Content-Type": 'any/thing'}) 142 | }) 143 | test('is not set by formData', () => { 144 | const formData = new FormData() 145 | formData.append("this", "value") 146 | const formDataRequest = new FetchRequest("get", "localhost", { body: formData }) 147 | expect(formDataRequest.fetchOptions.headers) 148 | .toStrictEqual(defaultHeaders) 149 | }) 150 | test('is set by file body', () => { 151 | const file = new File(["contenxt"], "file.txt", { type: "text/plain" }) 152 | const fileRequest = new FetchRequest("get", "localhost", { body: file }) 153 | expect(fileRequest.fetchOptions.headers) 154 | .toStrictEqual({ ...defaultHeaders, "Content-Type": "text/plain"}) 155 | }) 156 | test('is set by json body', () => { 157 | const jsonRequest = new FetchRequest("get", "localhost", { body: { some: "json"} }) 158 | expect(jsonRequest.fetchOptions.headers) 159 | .toStrictEqual({ ...defaultHeaders, "Content-Type": "application/json"}) 160 | }) 161 | }) 162 | 163 | test('additional headers are appended', () => { 164 | const request = new FetchRequest("get", "localhost", { contentType: "application/json", headers: { custom: "Header" } }) 165 | expect(request.fetchOptions.headers) 166 | .toStrictEqual({ ...defaultHeaders, custom: "Header", "Content-Type": "application/json"}) 167 | request.addHeader("test", "header") 168 | expect(request.fetchOptions.headers) 169 | .toStrictEqual({ ...defaultHeaders, custom: "Header", "Content-Type": "application/json", "test": "header"}) 170 | }) 171 | 172 | test('headers win over contentType', () => { 173 | const request = new FetchRequest("get", "localhost", { contentType: "application/json", headers: { "Content-Type": "this/overwrites" } }) 174 | expect(request.fetchOptions.headers) 175 | .toStrictEqual({ ...defaultHeaders, "Content-Type": "this/overwrites"}) 176 | }) 177 | 178 | test('serializes JSON to String', () => { 179 | const jsonBody = { some: "json" } 180 | let request 181 | request = new FetchRequest("get", "localhost", { body: jsonBody, contentType: "application/json" }) 182 | expect(request.fetchOptions.body).toBe(JSON.stringify(jsonBody)) 183 | 184 | request = new FetchRequest("get", "localhost", { body: jsonBody }) 185 | expect(request.fetchOptions.body).toBe(JSON.stringify(jsonBody)) 186 | }) 187 | 188 | test('not serializes JSON with explicit different contentType', () => { 189 | const jsonBody = { some: "json" } 190 | const request = new FetchRequest("get", "localhost", { body: jsonBody, contentType: "not/json" }) 191 | expect(request.fetchOptions.body).toBe(jsonBody) 192 | }) 193 | 194 | test('set redirect', () => { 195 | let request 196 | const redirectTypes = [ "follow", "error", "manual" ] 197 | for (const redirect of redirectTypes) { 198 | request = new FetchRequest("get", "localhost", { redirect }) 199 | expect(request.fetchOptions.redirect).toBe(redirect) 200 | } 201 | 202 | request = new FetchRequest("get", "localhost") 203 | expect(request.fetchOptions.redirect).toBe("follow") 204 | }) 205 | 206 | test('sets signal', () => { 207 | let request 208 | request = new FetchRequest("get", "localhost") 209 | expect(request.fetchOptions.signal).toBe(undefined) 210 | 211 | request = new FetchRequest("get", "localhost", { signal: "signal"}) 212 | expect(request.fetchOptions.signal).toBe("signal") 213 | }) 214 | 215 | test('has credentials setting which can be changed', () => { 216 | let request 217 | request = new FetchRequest("get", "localhost") 218 | expect(request.fetchOptions.credentials).toBe('same-origin') 219 | 220 | request = new FetchRequest("get", "localhost", { credentials: "include"}) 221 | expect(request.fetchOptions.credentials).toBe('include') 222 | }) 223 | 224 | test('has keepalive setting which can be changed', () => { 225 | let request 226 | request = new FetchRequest("get", "localhost") 227 | expect(request.fetchOptions.keepalive).toBe(false) 228 | 229 | request = new FetchRequest("get", "localhost", { keepalive: true}) 230 | expect(request.fetchOptions.keepalive).toBe(true) 231 | }) 232 | 233 | describe('csrf token inclusion', () => { 234 | // window.location.hostname is "localhost" in the test suite 235 | test('csrf token is not included in headers if url hostname is not the same as window.location (http)', () => { 236 | const request = new FetchRequest("get", "http://removeservice.com/test.json") 237 | expect(request.fetchOptions.headers).not.toHaveProperty("X-CSRF-Token") 238 | }) 239 | 240 | test('csrf token is not included in headers if url hostname is not the same as window.location (https)', () => { 241 | const request = new FetchRequest("get", "https://removeservice.com/test.json") 242 | expect(request.fetchOptions.headers).not.toHaveProperty("X-CSRF-Token") 243 | }) 244 | 245 | test('csrf token is included in headers if url hostname is the same as window.location', () => { 246 | const request = new FetchRequest("get", "http://localhost/test.json") 247 | expect(request.fetchOptions.headers).toHaveProperty("X-CSRF-Token") 248 | }) 249 | 250 | test('csrf token is included if url is a realative path', async () => { 251 | const defaultRequest = new FetchRequest("get", "/somepath") 252 | expect(defaultRequest.fetchOptions.headers).toHaveProperty("X-CSRF-Token") 253 | }) 254 | 255 | test('csrf token is included if url is not parseable', async () => { 256 | const defaultRequest = new FetchRequest("get", "not-a-url") 257 | expect(defaultRequest.fetchOptions.headers).toHaveProperty("X-CSRF-Token") 258 | }) 259 | }) 260 | }) 261 | 262 | describe('query params are parsed', () => { 263 | test('anchors are rejected', () => { 264 | const mixedRequest = new FetchRequest("post", "localhost/test?a=1&b=2#anchor", { query: { c: 3 } }) 265 | expect(mixedRequest.url).toBe("localhost/test?a=1&b=2&c=3") 266 | 267 | const queryRequest = new FetchRequest("post", "localhost/test?a=1&b=2&c=3#anchor") 268 | expect(queryRequest.url).toBe("localhost/test?a=1&b=2&c=3") 269 | 270 | const optionsRequest = new FetchRequest("post", "localhost/test#anchor", { query: { a: 1, b: 2, c: 3 } }) 271 | expect(optionsRequest.url).toBe("localhost/test?a=1&b=2&c=3") 272 | }) 273 | test('url and options are merged', () => { 274 | const urlAndOptionRequest = new FetchRequest("post", "localhost/test?a=1&b=2", { query: { c: 3 } }) 275 | expect(urlAndOptionRequest.url).toBe("localhost/test?a=1&b=2&c=3") 276 | }) 277 | test('only url', () => { 278 | const urlRequest = new FetchRequest("post", "localhost/test?a=1&b=2") 279 | expect(urlRequest.url).toBe("localhost/test?a=1&b=2") 280 | }) 281 | test('only options', () => { 282 | const optionRequest = new FetchRequest("post", "localhost/test", { query: { c: 3 } }) 283 | expect(optionRequest.url).toBe("localhost/test?c=3") 284 | }) 285 | test('options accept formData', () => { 286 | const formData = new FormData() 287 | formData.append("a", 1) 288 | 289 | const urlAndOptionRequest = new FetchRequest("post", "localhost/test", { query: formData }) 290 | expect(urlAndOptionRequest.url).toBe("localhost/test?a=1") 291 | }) 292 | test('options accept urlSearchParams', () => { 293 | const urlSearchParams = new URLSearchParams() 294 | urlSearchParams.append("a", 1) 295 | 296 | const urlAndOptionRequest = new FetchRequest("post", "localhost/test", { query: urlSearchParams }) 297 | expect(urlAndOptionRequest.url).toBe("localhost/test?a=1") 298 | }) 299 | test('urlSearchParams with list entries', () => { 300 | const urlSearchParams = new URLSearchParams() 301 | urlSearchParams.append("a[]", 1) 302 | urlSearchParams.append("a[]", 2) 303 | 304 | const urlAndOptionRequest = new FetchRequest("post", "localhost/test", {query: urlSearchParams}) 305 | expect(urlAndOptionRequest.url).toBe("localhost/test?a%5B%5D=1&a%5B%5D=2") 306 | }); 307 | test('handles empty query', () => { 308 | const emptyQueryRequest = new FetchRequest("get", "localhost/test") 309 | expect(emptyQueryRequest.url).toBe("localhost/test") 310 | }) 311 | }) 312 | 313 | 314 | describe('turbostream', () => { 315 | test('turbo fetch is called when available', async() => { 316 | const mockResponse = new Response("success!", { status: 200 }) 317 | 318 | window.fetch = jest.fn().mockResolvedValue(mockResponse) 319 | window.Turbo = { fetch: jest.fn().mockResolvedValue(mockResponse) } 320 | 321 | const testRequest = new FetchRequest("get", "localhost", { responseKind: 'turbo-stream' }) 322 | const testResponse = await testRequest.perform() 323 | 324 | expect(window.Turbo.fetch).toHaveBeenCalledTimes(1) 325 | expect(window.fetch).toHaveBeenCalledTimes(0) 326 | expect(testResponse).toStrictEqual(new FetchResponse(mockResponse)) 327 | }) 328 | 329 | test('turbo fetch is not called when not available', async() => { 330 | const mockResponse = new Response("success!", { status: 200 }) 331 | 332 | window.fetch = jest.fn().mockResolvedValue(mockResponse) 333 | window.Turbo = undefined 334 | 335 | const testRequest = new FetchRequest("get", "localhost", { responseKind: 'turbo-stream' }) 336 | const testResponse = await testRequest.perform() 337 | 338 | expect(window.fetch).toHaveBeenCalledTimes(1) 339 | expect(testResponse).toStrictEqual(new FetchResponse(mockResponse)) 340 | }) 341 | }) 342 | --------------------------------------------------------------------------------