Skip to content

Commit

Permalink
Fall back to text body (#96)
Browse files Browse the repository at this point in the history
* Add guard for 204 (don't just rely on content length)

* Add specs

* Bump patch

* Add option to return text of body if parsing fails

* Add specs

* Update docs and version
  • Loading branch information
chawes13 authored Sep 16, 2021
1 parent b54340d commit 36a6bbb
Show file tree
Hide file tree
Showing 6 changed files with 77 additions and 39 deletions.
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

0 comments on commit 36a6bbb

Please sign in to comment.