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

Add frontend request module wrapping fetch() #47

Merged
merged 1 commit into from
Dec 16, 2023
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
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