Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fall back to text body #96

Merged
merged 7 commits into from
Sep 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions __mocks__/isomorphic-fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,34 @@ const statuses = {
[unauthorizedUrl]: 401
}

export default jest.fn(function (url, options) {
const { headers={} } = options
const body = { ...options, url }
export class MockResponse {
#text

const response = {
// Response echoes back passed options
headers: {
get: (header) => {
return headers[header]
},
...headers,
},
json: () => Promise.resolve(body),
ok: ![failureUrl, unauthorizedUrl].includes(url),
status: statuses[url]
constructor(url, options, body = null) {
const { headers={} } = options
this.headers = {
get: (header) => headers[header],
...headers
}
this.body = body ?? { ...options, url }
this.ok = ![failureUrl, unauthorizedUrl].includes(url)
this.status = statuses[url]
this._config = options
this.#text = typeof body === 'string' ? body : JSON.stringify(this.body)
}

json() {
return Promise.resolve(this.body)
}

text() {
return Promise.resolve(this.#text)
}
}

export default jest.fn(function fetch(url, options) {
const response = new MockResponse(url, options)

// Simulate server response
return new Promise((resolve, reject) => {
setTimeout(
Expand Down
1 change: 1 addition & 0 deletions docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ In addition to the normal Fetch API settings, the config object may also contain
- `camelizeResponse`: A boolean flag indicating whether or not to camelize the response keys (default=`true`). The helper function that does this is also exported from this library as `camelizeKeys`.
- `decamelizeBody`: A boolean flag indicating whether or not to decamelize the body keys (default=`true`). The helper function that does this is also exported from this library as `decamelizeKeys`.
- `decamelizeQuery`: A boolean flag indicating whether or not to decamelize the query string keys (default=`true`).
- `parseJsonStrictly`: A boolean flag indicating whether or not to return the text of the response body if JSON parsing fails (default=`true`). If set to `true` and invalid JSON is received in the response, then `null` will be returned instead.
- `auth`: An object with the following keys `{ username, password }`. If present, `http` will use [basic auth][28], adding the header `"Authorization": "Basic <authToken>"` to the request, where `<authToken>` is a base64 encoded string of `username:password`.

### Parameters
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@launchpadlab/lp-requests",
"version": "4.1.9",
"version": "4.2.0",
"description": "Request helpers",
"main": "lib/index.js",
"scripts": {
Expand Down
22 changes: 15 additions & 7 deletions src/http/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
* - `camelizeResponse`: A boolean flag indicating whether or not to camelize the response keys (default=`true`). The helper function that does this is also exported from this library as `camelizeKeys`.
* - `decamelizeBody`: A boolean flag indicating whether or not to decamelize the body keys (default=`true`). The helper function that does this is also exported from this library as `decamelizeKeys`.
* - `decamelizeQuery`: A boolean flag indicating whether or not to decamelize the query string keys (default=`true`).
* - `parseJsonStrictly`: A boolean flag indicating whether or not to return the text of the response body if JSON parsing fails (default=`true`). If set to `true` and invalid JSON is received in the response, then `null` will be returned instead.
* - `auth`: An object with the following keys `{ username, password }`. If present, `http` will use [basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication#Client_side), adding the header `"Authorization": "Basic <authToken>"` to the request, where `<authToken>` is a base64 encoded string of `username:password`.
*
* @name http
Expand Down Expand Up @@ -85,15 +86,21 @@ export function parseArguments (...args) {
}

// Get JSON from response
async function getResponseBody(response) {
async function getResponseBody(response, { parseJsonStrictly }) {
// Don't parse empty body
if (response.headers.get('Content-Length') === '0' || response.status === 204) return null

let data
try {
return await response.json()
data = await response.text()
return JSON.parse(data)
} catch (e) {
// eslint-disable-next-line
console.warn('Failed to parse response body: ' + e, response)
return null
if (parseJsonStrictly) {
// eslint-disable-next-line
console.warn('Failed to parse response body: ' + e, response)
return null
}
return data
}
}

Expand Down Expand Up @@ -123,13 +130,14 @@ async function http (...args) {
failureDataPath,
url,
fetchOptions,
parseJsonStrictly,
} = parsedOptions
// responseData is either the body of the response, or an error object.
const responseData = await attemptAsync(async () => {
// Make request
const response = await fetch(url, fetchOptions)
const response = __mock_response ?? await fetch(url, fetchOptions)
// Parse the response
const body = __mock_response || await getResponseBody(response)
const body = await getResponseBody(response, { parseJsonStrictly })
const data = camelizeResponse ? camelizeKeys(body) : body
if (!response.ok) {
const errors = getDataAtPath(data, failureDataPath)
Expand Down
5 changes: 3 additions & 2 deletions src/http/middleware/set-defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { identity } from '../../utils'
const DEFAULTS = {
credentials: 'same-origin',
mode: 'same-origin',
onSuccess: identity,
onSuccess: identity,
onFailure: identity,
camelizeResponse: true,
failureDataPath: 'errors',
parseJsonStrictly: true,
}

const DEFAULT_HEADERS = {
Expand All @@ -25,4 +26,4 @@ function setDefaults ({ headers={}, overrideHeaders=false, ...rest }) {
}
}

export default setDefaults
export default setDefaults
46 changes: 31 additions & 15 deletions test/http/http.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Base64 from 'Base64'
import { successUrl, noContentUrl, failureUrl } from 'isomorphic-fetch'
import { successUrl, noContentUrl, failureUrl, MockResponse } from 'isomorphic-fetch'
import { http } from '../../src'

// These tests rely on the mock Fetch()
Expand Down Expand Up @@ -81,7 +81,7 @@ test('http modifies fetch configuration using `before` hook', () => {

test('http modifies overall configuration using `before` hook', () => {
const before = () => ({ successDataPath: 'foo' })
return http(successUrl, { before, __mock_response: { foo: 'bar' } }).then((res) => {
return httpWithMock(successUrl, { before }, { foo: 'bar' }).then((res) => {
expect(res).toEqual('bar')
})
})
Expand Down Expand Up @@ -180,12 +180,11 @@ test('http pulls data from response using successDataPath', () => {
test('http failureDataPath defaults to "errors"', () => {
expect.assertions(1)
const ERRORS = { 'someValue': 'there was an error' }
return http(failureUrl, {
return httpWithMock(failureUrl, {
method: 'POST',
__mock_response: {
}, {
errors: ERRORS,
}
}).catch((err) => {
}).catch((err) => {
expect(err.errors).toEqual(ERRORS)
})
})
Expand Down Expand Up @@ -231,23 +230,21 @@ test('http does not decamelizes query if decamelizeQuery is false', () => {
})

test('http camelizes json response by default', () => {
return http(successUrl, {
return httpWithMock(successUrl, {
method: 'POST',
__mock_response: {
}, {
camelized_key: 'a camelized key'
},
}).then((res) => {
}).then((res) => {
expect(res).toHaveProperty('camelizedKey')
})
})

test('http does not camelizes json response if camelize passed as false', () => {
return http(successUrl, {
test('http does not camelize json response if camelize passed as false', () => {
return httpWithMock(successUrl, {
camelizeResponse: false,
__mock_response: {
}, {
Capitalized_key: 'a weirdly cased key'
}
}).then((res) => {
}).then((res) => {
expect(res).toHaveProperty('Capitalized_key')
})
})
Expand Down Expand Up @@ -303,8 +300,27 @@ test('http returns null when the status is 204 (no content)', () => {
})
})

test('http returns null when json parsing fails by default', () => {
return httpWithMock(successUrl, {}, "123AZY")
.then((res) => {
expect(res).toBe(null)
})
})

test('http returns text of body when json parsing fails and parseJsonStrictly is `false`', () => {
return httpWithMock(successUrl, { parseJsonStrictly: false }, "123AZY")
.then((res) => {
expect(res).toBe('123AZY')
})
})

/* MOCK STUFF */

async function httpWithMock(url, options, responseBody) {
const mockResponse = new MockResponse(url, options, responseBody)
return http(url, { ...options, __mock_response: mockResponse })
}

// Mock token elements
const createTokenTag = (name, content) => {
const tag = document.createElement('meta')
Expand Down