Skip to content

Commit

Permalink
Merge pull request #47 from mbland/request-class
Browse files Browse the repository at this point in the history
Add frontend request module wrapping fetch()
  • Loading branch information
mbland authored Dec 16, 2023
2 parents 8418591 + f76817e commit 99b75f0
Show file tree
Hide file tree
Showing 3 changed files with 136 additions and 1 deletion.
50 changes: 50 additions & 0 deletions strcalc/src/main/frontend/components/request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

/**
* Posts the data from a <form> via fetch() and returns the response object
* @see https://simonplend.com/how-to-use-fetch-to-post-form-data-as-json-to-your-api/
* @param {FormData} form - form containing data to POST
* @returns {Promise<any>} - response from the server
*/
export async function postForm(form) {
return post(form.action, Object.fromEntries(new FormData(form).entries()))
}

/**
* Posts an object payload via fetch() and returns the response object
* @param {string} url - address of server request
* @param {object} payload - data to include in the POST request
* @returns {Promise<any>} - response from the server
*/
export async function post(url, payload) {
const res = await fetch(url, postOptions(payload))
const body = await res.text()

if (body.startsWith('{') && body.includes('"error":')) {
throw new Error(JSON.parse(body).error)
} else if (!res.ok) {
const msg = body.length !== 0 ? body : `${res.status}: ${res.statusText}`
throw new Error(msg)
}
return JSON.parse(body)
}

/**
* Prepares the fetch() options for an application/json POST request
* @param {object} payload - data to include in the POST request options
* @returns {object} - an options object for a fetch() POST request
*/
export function postOptions(payload) {
return {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(payload)
}
}
85 changes: 85 additions & 0 deletions strcalc/src/main/frontend/components/request.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/* eslint-env browser, node, jest, vitest */
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { post, postForm, postOptions } from './request'
import { afterEach, describe, expect, test, vi } from 'vitest'

// @vitest-environment jsdom
describe('Request', () => {
const req = { want: 'foo' }

const setupFetchStub = (body, options) => {
const fetchStub = vi.fn()

fetchStub.mockReturnValueOnce(Promise.resolve(new Response(body, options)))
vi.stubGlobal('fetch', fetchStub)
return fetchStub
}

afterEach(() => { vi.unstubAllGlobals() })

describe('post', () => {
test('succeeds', async () => {
const res = { foo: 'bar' }
const fetchStub = setupFetchStub(JSON.stringify(res))

await expect(post('/fetch', req)).resolves.toEqual(res)
expect(fetchStub).toHaveBeenCalledWith('/fetch', postOptions(req))
})

test('rejects with an error if the response contains "error"', async () => {
const res = { error: 'OK status, but still an error' }
setupFetchStub(JSON.stringify(res))

await expect(post('/fetch', req)).rejects.toThrow(res.error)
})

test('rejects with an error if the response status is not OK', async () => {
const res = 'totally our fault'
setupFetchStub(res, { status: 500 })

await expect(post('/fetch', req)).rejects.toThrow(res)
})

test('rejects with default status text if no response body', async () => {
setupFetchStub('', { status: 500, statusText: 'Internal Server Error' })

await expect(post('/fetch', req))
.rejects.toThrow('500: Internal Server Error')
})
})

describe('postForm', () => {
test('succeeds', async () => {
// We have to be careful creating the <form>, because form.action resolves
// differently depending on how we created it.
//
// Originally I tried creating it using fragment() from '../test/helpers',
// which creates elements using a new <template> containing a
// DocumentFragment. However, the elements in that DocumentFragment are in
// a separate DOM. This caused the <form action="/fetch"> attribute to be:
//
// - '/fetch' in jsdom
// - '' in Chrome
// - `#{document.location.origin}/fetch` in Firefox
//
// Creating a <form> element via document.createElement() as below
// causes form.action to become `#{document.location.origin}/fetch` in
// every environment.
const form = document.createElement('form')
const resolvedAction = `${document.location.origin}/fetch`
const res = { foo: 'bar' }
const fetchStub = setupFetchStub(JSON.stringify(res))

form.action = '/fetch'
form.innerHTML = '<input type="text" name="want" id="want" value="foo" />'

expect(form.action).toBe(resolvedAction)
await expect(postForm(form)).resolves.toEqual(res)
expect(fetchStub).toHaveBeenCalledWith(resolvedAction, postOptions(req))
})
})
})
2 changes: 1 addition & 1 deletion strcalc/src/main/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"test": "vitest",
"test-run": "vitest run",
"test-ui": "vitest --ui --coverage",
"test-ci": "eslint --color --max-warnings 0 . && vitest run --config ci/vitest.config.js && vitest run --config ci/vitest.config.js",
"test-ci": "eslint --color --max-warnings 0 . && vitest run --config ci/vitest.config.js && vitest run --config ci/vitest.config.browser.js",
"coverage": "vitest run --coverage",
"jsdoc": "bin/jsdoc -c ./jsdoc.json ."
},
Expand Down

0 comments on commit 99b75f0

Please sign in to comment.