Skip to content

Commit

Permalink
Add convenience methods to request() functions for HTTP verbs (#765)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthew-white authored Apr 14, 2023
1 parent a80ea5d commit a6b3611
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 10 deletions.
4 changes: 2 additions & 2 deletions src/composables/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ running watchers and updating the DOM.

import { inject, readonly, ref, watch } from 'vue';

import { isProblem, logAxiosError, requestAlertMessage, withAuth } from '../util/request';
import { isProblem, logAxiosError, requestAlertMessage, withAuth, withHttpMethods } from '../util/request';

const _request = (container, awaitingResponse) => (config) => {
const { router, i18n, requestData, alert, http, logger } = container;
Expand Down Expand Up @@ -128,7 +128,7 @@ const _request = (container, awaitingResponse) => (config) => {
export default () => {
const container = inject('container');
const awaitingResponse = ref(false);
const request = _request(container, awaitingResponse);
const request = withHttpMethods(_request(container, awaitingResponse));

const { router } = container;
// `router` may be `null` in testing.
Expand Down
18 changes: 15 additions & 3 deletions src/request-data/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ except according to the terms contained in the LICENSE file.
*/
import { computed, isRef, readonly, toRef } from 'vue';

import { isProblem, logAxiosError, requestAlertMessage, withAuth } from '../util/request';
import { isProblem, logAxiosError, requestAlertMessage, withAuth, withHttpMethods } from '../util/request';
import { noop } from '../util/util';
import { setCurrentResource } from './util';
import { unlessFailure } from '../util/router';
Expand Down Expand Up @@ -279,8 +279,20 @@ class Resource extends BaseResource {
}

const proxyHandler = {
get: (resource, prop) => {
if (prop in resource) return resource[prop];
get: (resource, prop, proxy) => {
// First check the resource for the property.
if (prop in resource) {
// We want to call withHttpMethods() on Resource.prototype.request().
// However, because the request() method references `this`, we must first
// bind it to the proxy. For example, if resource.request.post() is
// called, we need `this` to be the proxy in post().
if (prop === 'request')
return withHttpMethods(resource.request.bind(proxy));

return resource[prop];
}

// Fall back to getting the property from resource.data.
const { data } = resource;
if (data == null) return undefined;
const value = data[prop];
Expand Down
41 changes: 37 additions & 4 deletions src/util/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ except according to the terms contained in the LICENSE file.
*/
import { odataLiteral } from './odata';

// Returns `true` if `data` looks like a Backend Problem and `false` if not.
export const isProblem = (data) => data != null && typeof data === 'object' &&
typeof data.code === 'number' && typeof data.message === 'string';



////////////////////////////////////////////////////////////////////////////////
// API PATHS

export const queryString = (query) => {
if (query == null) return '';
const entries = Object.entries(query);
Expand Down Expand Up @@ -161,6 +170,34 @@ export const apiPaths = {
audits: (query) => `/v1/audits${queryString(query)}`
};



////////////////////////////////////////////////////////////////////////////////
// SENDING REQUESTS

const httpMethods = {};
for (const prop of ['get', 'delete']) {
const method = prop.toUpperCase();
// eslint-disable-next-line func-names
httpMethods[prop] = function(url, config = undefined) {
return this({ ...config, method, url });
};
}
for (const prop of ['post', 'put', 'patch']) {
const method = prop.toUpperCase();
// eslint-disable-next-line func-names
httpMethods[prop] = function(url, data = undefined, config = undefined) {
const full = { ...config, method, url };
if (data != null) full.data = data;
return this(full);
};
}

// Adds convenience methods for HTTP verbs to a request() function. `this`
// cannot be passed through to the convenience methods, so if the request()
// function references `this`, you must bind `this` for it first.
export const withHttpMethods = (f) => Object.assign(f, httpMethods);

export const withAuth = (config, token) => {
const { headers } = config;
if ((headers == null || headers.Authorization == null) &&
Expand All @@ -173,10 +210,6 @@ export const withAuth = (config, token) => {
return config;
};

// Returns `true` if `data` looks like a Backend Problem and `false` if not.
export const isProblem = (data) => data != null && typeof data === 'object' &&
typeof data.code === 'number' && typeof data.message === 'string';

export const logAxiosError = (logger, error) => {
if (error.response == null)
logger.log(error.request != null ? error.request : error.message);
Expand Down
15 changes: 15 additions & 0 deletions test/composables/request.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ const TestUtilRequest = {
};

describe('useRequest()', () => {
it('sends the correct request', () =>
mockHttp()
.mount(TestUtilRequest)
.request(component =>
component.vm.request({ method: 'DELETE', url: '/v1/projects/1' }))
.respondWithSuccess()
.testRequests([{ method: 'DELETE', url: '/v1/projects/1' }]));

it('provides convenience methods for HTTP verbs', () =>
mockHttp()
.mount(TestUtilRequest)
.request(component => component.vm.request.delete('/v1/projects/1'))
.respondWithSuccess()
.testRequests([{ method: 'DELETE', url: '/v1/projects/1' }]));

describe('awaitingResponse', () => {
it('sets awaitingResponse to true during the request', () =>
mockHttp()
Expand Down
28 changes: 28 additions & 0 deletions test/request-data/resource.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,34 @@ describe('createResource()', () => {
});
});
});

describe('convenience methods for HTTP verbs', () => {
it('sends the correct request', () => {
const container = createTestContainer();
const { project } = container.requestData;
return mockHttp(container)
.request(() => project.request.get('/v1/projects/1', {
// This option should be passed to request() even though it is a
// request() option, not an axios option.
extended: true
}))
.respondWithData(() => testData.extendedProjects.createNew())
.testRequests([
{ method: 'GET', url: '/v1/projects/1', extended: true }
]);
});

it('stores the response', () => {
const container = createTestContainer();
const { project } = container.requestData;
return mockHttp(container)
.request(() => project.request.get('/v1/projects/1'))
.respondWithData(() => testData.standardProjects.createNew())
.afterResponse(() => {
project.dataExists.should.be.true();
});
});
});
});

describe('cancelRequest()', () => {
Expand Down
111 changes: 110 additions & 1 deletion test/unit/request.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import sinon from 'sinon';

import createCentralI18n from '../../src/i18n';
import { apiPaths, isProblem, logAxiosError, queryString, requestAlertMessage, withAuth } from '../../src/util/request';
import { apiPaths, isProblem, logAxiosError, queryString, requestAlertMessage, withAuth, withHttpMethods } from '../../src/util/request';

import { mockAxiosError } from '../util/axios';
import { mockLogger } from '../util/util';
Expand Down Expand Up @@ -372,6 +374,113 @@ describe('util/request', () => {
});
});

// Right now, withHttpMethods() modifies the request() function that is passed
// to it. However, an earlier version returned a proxy of the function. These
// tests were written for that earlier version, so they do not assume that
// withHttpMethods() returns the same function that was passed to it.
describe('withHttpMethods()', () => {
it('returns a result that calls the request() function', () => {
const request = sinon.fake(() => 'foo');
const result = withHttpMethods(request);
result({ method: 'GET', url: '/v1/projects/1' }).should.equal('foo');
request.args[0][0].should.eql({
method: 'GET',
url: '/v1/projects/1'
});
});

it('adds a convenience method for GET', () => {
const request = sinon.fake(() => 'foo');
const result = withHttpMethods(request);
result.get('/v1/projects/1').should.equal('foo');
request.args[0][0].should.eql({
method: 'GET',
url: '/v1/projects/1'
});
});

it('accepts a config argument for GET', () => {
const request = sinon.fake();
withHttpMethods(request).get('/v1/projects/1', {
headers: { 'X-Foo': 'bar' }
});
request.args[0][0].should.eql({
method: 'GET',
url: '/v1/projects/1',
headers: { 'X-Foo': 'bar' }
});
});

it('adds a convenience method for DELETE', () => {
const request = sinon.fake();
withHttpMethods(request).delete('/v1/projects/1');
request.args[0][0].should.eql({
method: 'DELETE',
url: '/v1/projects/1'
});
});

it('adds a convenience method for POST', () => {
const request = sinon.fake();
withHttpMethods(request).post('/v1/projects');
request.args[0][0].should.eql({
method: 'POST',
url: '/v1/projects'
});
});

it('accepts a data argument for POST', () => {
const request = sinon.fake();
withHttpMethods(request).post('/v1/projects', { foo: 'bar' });
request.args[0][0].should.eql({
method: 'POST',
url: '/v1/projects',
data: { foo: 'bar' }
});
});

it('accepts a config argument for POST', () => {
const request = sinon.fake();
withHttpMethods(request).post('/v1/projects', { foo: 'bar' }, {
headers: { 'X-Foo': 'baz' }
});
request.args[0][0].should.eql({
method: 'POST',
url: '/v1/projects',
data: { foo: 'bar' },
headers: { 'X-Foo': 'baz' }
});
});

it('adds a convenience method for PUT', () => {
const request = sinon.fake();
withHttpMethods(request).put('/v1/projects/1');
request.args[0][0].should.eql({
method: 'PUT',
url: '/v1/projects/1'
});
});

it('adds a convenience method for PATCH', () => {
const request = sinon.fake();
withHttpMethods(request).patch('/v1/projects/1');
request.args[0][0].should.eql({
method: 'PATCH',
url: '/v1/projects/1'
});
});

it('returns other properties on the request() function', () => {
const request = (x) => x;
request.length.should.equal(1);
withHttpMethods(request).length.should.equal(1);
});

it('returns undefined for a property that is not on the function', () => {
should(withHttpMethods(() => {}).foo).be.undefined();
});
});

describe('withAuth()', () => {
it('specifies the session token in the Authorization header', () => {
withAuth({ url: '/v1/users' }, 'xyz').should.eql({
Expand Down

0 comments on commit a6b3611

Please sign in to comment.