Skip to content

Commit

Permalink
pkp/pkp-lib#9527 Refine useFetch, useFetchPaginated, add typical test…
Browse files Browse the repository at this point in the history
…s scenario, support csrf and x-http-methor-override
  • Loading branch information
jardakotesovec committed Jan 8, 2024
1 parent 903a1fa commit d55b056
Show file tree
Hide file tree
Showing 5 changed files with 366 additions and 79 deletions.
58 changes: 56 additions & 2 deletions src/composables/useFetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ import {ofetch, createFetch} from 'ofetch';
import {useDialogStore} from '@/stores/dialogStore';

let ofetchInstance = ofetch;
export function useFetch(url, options) {

function getCSRFToken() {
const FALLBACK_TOKEN = 'test_csrf_token';

if (typeof pkp !== 'undefined') {
return pkp?.currentUser?.csrfToken || FALLBACK_TOKEN;
}

return FALLBACK_TOKEN;
}

export function useFetch(url, options = {}) {
/**
* Workaround for testing https://github.com/unjs/ofetch/issues/295
* Can be removed once issue is addressed
Expand All @@ -13,9 +24,21 @@ export function useFetch(url, options) {
ofetchInstance = createFetch();
}

const {
expectValidationError,
query: _query,
body: _body,
...ofetchOptions
} = options;

const query = ref(_query || {});
const body = ref(_body || undefined);

const dialogStore = useDialogStore();
const isLoading = ref(false);
const data = ref(null);
const error = ref(null);
const validationError = ref(null);

let lastRequestController = null;

Expand All @@ -28,12 +51,33 @@ export function useFetch(url, options) {

const signal = lastRequestController.signal;

const opts = {...options, signal};
const opts = {
...ofetchOptions,
query: query.value,
body: body.value,
signal,
};

// add csrf token
if (['POST', 'DELETE', 'PUT'].includes(opts.method)) {
if (!opts.headers) {
opts.headers = {};
}

opts.headers['X-Csrf-Token'] = getCSRFToken();
// add method-override for improved server compatibility https://github.com/pkp/pkp-lib/issues/5981
if (['DELETE', 'PUT'].includes(opts?.method)) {
opts.headers['X-Http-Method-Override'] = options.method;
opts.method = 'POST';
}
}

isLoading.value = true;
try {
const result = await ofetchInstance(unref(url), opts);
data.value = result;
error.value = null;
validationError.value = null;
} catch (e) {
data.value = null;

Expand All @@ -44,7 +88,15 @@ export function useFetch(url, options) {
return; // aborted by subsequent request
}

if (expectValidationError && e.status >= 400 && e.status < 500) {
validationError.value = e.data;
error.value = null;
data.value = null;
return;
}

dialogStore.openDialogNetworkError(e);
error.value = e;
} finally {
lastRequestController = null;
isLoading.value = false;
Expand All @@ -53,6 +105,8 @@ export function useFetch(url, options) {

return {
data,
error,
validationError,
isLoading,
fetch,
};
Expand Down
158 changes: 150 additions & 8 deletions src/composables/useFetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,26 @@ import {useFetch} from './useFetch';
import {useDialogStore} from '@/stores/dialogStore';

export const restHandlers = [
http.get('http://mock/delayed', async ({request}) => {
http.get('http://mock/get/status200', async ({request}) => {
const url = new URL(request.url);

let params = new URLSearchParams(url.search);

// To store the parameters in a simple object
let allParams = {};
for (let param of params) {
allParams[param[0]] = param[1];
}

return HttpResponse.json({
itemsMax: 2,
items: [{title: 'a'}, {title: 'b'}],
// to be able assert that it send queryParams correctly
queryParams: allParams,
});
}),

http.get('http://mock/get/delayed', async ({request}) => {
const url = new URL(request.url);

const id = parseInt(url.searchParams.get('id'));
Expand All @@ -26,9 +45,35 @@ export const restHandlers = [
return HttpResponse.json({id});
}),

http.get('http://mock/status500', async ({request}) => {
http.get('http://mock/get/status500', async ({request}) => {
return new HttpResponse(null, {status: 500});
}),

http.post('http://mock/post/status200', async ({request}) => {
const postBody = await request.json();

return HttpResponse.json(postBody, {status: 200});
}),

http.post('http://mock/post/status400', async ({request}) => {
return HttpResponse.json(
{
title: ['has to be longer'],
},
{status: 400},
);
}),

http.post('http://mock/put/status200', async ({request}) => {
const postBody = await request.json();

const requestHeaders = {};
request.headers.forEach((value, key) => {
requestHeaders[key] = value;
});

return HttpResponse.json({body: postBody, headers: requestHeaders});
}),
];

const server = setupServer(...restHandlers);
Expand All @@ -46,20 +91,117 @@ beforeEach(() => {
setActivePinia(createPinia());
});

describe('useFetch', () => {
describe('typical uses', () => {
test('GET 200 request', async () => {
const url = ref('http://mock/get/status200');
const {data, error, isLoading, fetch} = useFetch(url, {
query: {param1: 4, param2: 5},
});

expect(isLoading.value).toBe(false);
const fetchPromise = fetch();
expect(isLoading.value).toBe(true);

await fetchPromise;

expect(isLoading.value).toBe(false);
expect(error.value).toBe(null);
expect(data.value).toMatchInlineSnapshot(`
{
"items": [
{
"title": "a",
},
{
"title": "b",
},
],
"itemsMax": 2,
"queryParams": {
"param1": "4",
"param2": "5",
},
}
`);
});
test('POST 200 request', async () => {
const body = {title: 'abc'};
const url = ref('http://mock/post/status200');
const {data, error, validationError, fetch} = useFetch(url, {
method: 'POST',
body,
expectValidationError: true,
});

await fetch();

expect(error.value).toBe(null);
expect(validationError.value).toBe(null);

expect(data.value).toStrictEqual(body);
});

test('POST 400 validation error request', async () => {
const url = ref('http://mock/post/status400');
const {data, error, validationError, fetch} = useFetch(url, {
method: 'POST',
body: {title: 'abc'},
headers: {'X-Csrf-Token': '___TOKEN___'},
expectValidationError: true,
});

await fetch();

expect(error.value).toBe(null);
expect(validationError.value).toStrictEqual({
title: ['has to be longer'],
});

expect(data.value).toBe(null);
});

test('PUT 200 request', async () => {
const url = ref('http://mock/put/status200');
const {data, error, validationError, fetch} = useFetch(url, {
method: 'PUT',
body: {title: 'abc'},
expectValidationError: true,
});

await fetch();

expect(error.value).toBe(null);
expect(validationError.value).toBe(null);
expect(data.value).toMatchInlineSnapshot(`
{
"body": {
"title": "abc",
},
"headers": {
"accept": "application/json",
"content-type": "application/json",
"x-csrf-token": "test_csrf_token",
"x-http-method-override": "PUT",
},
}
`);
});
});

describe('features', () => {
test('last request data is used, previous are aborted', async () => {
const url = ref('http://mock/delayed?id=5');
const {data, fetch} = useFetch(url);
const url = ref('http://mock/get/delayed');
const query = ref({id: 5});
const {data, fetch} = useFetch(url, {query});
const longFetch = fetch();

url.value = 'http://mock/delayed?id=1';
query.value.id = 1;
const shortFetch = fetch();
await Promise.all([longFetch, shortFetch]);
expect(data.value).toStrictEqual({id: 1});
});

test('network dialog error is displayed if there is http code other than 2XX', async () => {
const url = ref('http://mock/status500');
const url = ref('http://mock/get/status500');
const dialogStore = useDialogStore();
expect(dialogStore.dialogOpened).toBe(false);

Expand Down
74 changes: 17 additions & 57 deletions src/composables/useFetchPaginated.js
Original file line number Diff line number Diff line change
@@ -1,75 +1,35 @@
import {ref, computed, unref} from 'vue';
import {ofetch} from 'ofetch';
import {useDialogStore} from '@/stores/dialogStore';
import {ref, computed} from 'vue';

export function useFetchPaginated(url, options) {
const dialogStore = useDialogStore();
import {useFetch} from './useFetch';

export function useFetchPaginated(url, options) {
const {
query: _query,
page: _page,
pageSize: _pageSize,
...fetchOpts
query: _query,
...useFetchOpts
} = options;

const query = ref(_query || {});

// normalise to make these options reactive if they are not already
const page = ref(_page);
const pageSize = ref(_pageSize);
const query = ref(_query || {});

const isLoading = ref(false);
const itemCount = ref(0);
const items = ref([]);
// add offset and count to query params
const offset = computed(() => {
return (page.value - 1) * pageSize.value;
});
const useFetchQuery = computed(() => {
return {...query.value, offset: offset.value, count: pageSize.value};
});

let lastRequestController = null;

async function fetch() {
if (lastRequestController) {
// abort in-flight request
lastRequestController.abort();
}
lastRequestController = new AbortController();

const queryParams = {
offset: offset.value,
count: pageSize.value,
...query.value,
};

const signal = lastRequestController.signal;

const opts = {
query: queryParams,
...fetchOpts,
signal,
};

isLoading.value = true;
try {
const result = await ofetch(unref(url), opts);
items.value = result.items;
itemCount.value = result.itemsMax;
} catch (e) {
items.value = [];
itemCount.value = 0;

if (signal) {
e.aborted = signal.aborted;
}

if (e.aborted) {
return; // aborted by subsequent request
}
const {data, isLoading, fetch} = useFetch(url, {
...useFetchOpts,
query: useFetchQuery,
});

dialogStore.openDialogNetworkError(e);
} finally {
lastRequestController = null;
isLoading.value = false;
}
}
const items = computed(() => data.value?.items);
const itemCount = computed(() => data.value?.itemsMax);

const pagination = computed(() => {
const firstItemIndex = itemCount.value ? offset.value + 1 : 0;
Expand Down
Loading

0 comments on commit d55b056

Please sign in to comment.