feat(ui): add a fetch hook for sending requests to any of cluster members

This commit is contained in:
Łukasz Mierzwa
2020-06-06 19:52:20 +01:00
committed by Łukasz Mierzwa
parent 20956aaf68
commit b09dd6f8c9
2 changed files with 402 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { useState, useEffect } from "react";
import merge from "lodash.merge";
import { CommonOptions } from "Common/Fetch";
const useFetchAny = (upstreams) => {
const [index, setIndex] = useState(0);
const [response, setResponse] = useState(null);
const [error, setError] = useState(null);
const [inProgress, setInProgress] = useState(true);
const [responseURI, setResponseURI] = useState(null);
useEffect(() => {
// https://dev.to/pallymore/clean-up-async-requests-in-useeffect-hooks-90h
let isCancelled = false;
const fetchData = async () => {
const { uri, options } = upstreams[index];
try {
setInProgress(true);
const res = await fetch(
uri,
merge({}, { method: "GET" }, CommonOptions, options)
);
if (!isCancelled) {
let body;
const contentType = res.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
body = await res.json();
} else {
body = await res.text();
}
if (res.ok) {
setResponse(body);
setResponseURI(uri);
setInProgress(false);
} else {
if (upstreams.length > index + 1) {
setIndex(index + 1);
} else {
setError(body);
setInProgress(false);
}
}
}
} catch (error) {
if (!isCancelled) {
if (upstreams.length > index + 1) {
setIndex(index + 1);
} else {
setError(error.message);
setInProgress(false);
}
}
}
};
if (upstreams.length > 0) {
fetchData();
} else {
setInProgress(false);
}
return () => {
isCancelled = true;
};
}, [upstreams, index]);
return { response, error, inProgress, responseURI };
};
export { useFetchAny };

View File

@@ -0,0 +1,326 @@
import React from "react";
import { renderHook } from "@testing-library/react-hooks";
import { mount } from "enzyme";
import fetchMock from "fetch-mock";
import { useFetchAny } from "./useFetchAny";
describe("useFetchAny", () => {
beforeAll(() => {
fetchMock.mock("http://localhost/ok", "body ok");
fetchMock.mock("http://localhost/ok/json", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ok" }),
});
fetchMock.mock("http://localhost/500", {
status: 500,
body: "fake error",
});
fetchMock.mock("http://localhost/error", {
throws: new TypeError("failed to fetch"),
});
});
beforeEach(() => {
fetchMock.resetHistory();
});
afterEach(() => {
fetchMock.resetHistory();
});
it("does nothing on empty upstream list", async () => {
const upstreams = [];
const { result } = renderHook(() => useFetchAny(upstreams));
expect(fetchMock.calls()).toHaveLength(0);
expect(result.current.inProgress).toBe(false);
});
it("sends a GET request by default", async () => {
const upstreams = [{ uri: "http://localhost/ok", options: {} }];
const { waitForNextUpdate } = renderHook(() => useFetchAny(upstreams));
await waitForNextUpdate();
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastCall()[0]).toBe("http://localhost/ok");
expect(fetchMock.lastCall()[1]).toMatchObject({
method: "GET",
credentials: "include",
mode: "cors",
redirect: "follow",
});
});
it("uses options from upstream", async () => {
const upstreams = [
{
uri: "http://localhost/ok",
options: { method: "POST", credentials: "same-site" },
},
];
const { waitForNextUpdate } = renderHook(() => useFetchAny(upstreams));
await waitForNextUpdate();
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastCall()[0]).toBe("http://localhost/ok");
expect(fetchMock.lastCall()[1]).toMatchObject({
method: "POST",
credentials: "same-site",
mode: "cors",
redirect: "follow",
});
});
it("sends correct headers", async () => {
const upstreams = [{ uri: "http://localhost/ok", options: {} }];
const { waitForNextUpdate } = renderHook(() => useFetchAny(upstreams));
await waitForNextUpdate();
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastCall()[0]).toBe("http://localhost/ok");
expect(fetchMock.lastCall()[1]).toMatchObject({
mode: "cors",
credentials: "include",
redirect: "follow",
});
});
it("plain response is updated after successful fetch", async () => {
const upstreams = [{ uri: "http://localhost/ok", options: {} }];
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAny(upstreams)
);
expect(result.current.response).toBe(null);
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(true);
expect(result.current.responseURI).toBe(null);
await waitForNextUpdate();
expect(result.current.response).toBe("body ok");
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe("http://localhost/ok");
});
it("JSON response is updated after successful fetch", async () => {
const upstreams = [{ uri: "http://localhost/ok/json", options: {} }];
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAny(upstreams)
);
expect(result.current.response).toBe(null);
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(true);
expect(result.current.responseURI).toBe(null);
await waitForNextUpdate();
expect(result.current.response).toMatchObject({ status: "ok" });
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe("http://localhost/ok/json");
});
it("error is updated after 500 error", async () => {
const upstreams = [{ uri: "http://localhost/500", options: {} }];
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAny(upstreams)
);
expect(result.current.response).toBe(null);
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(true);
expect(result.current.responseURI).toBe(null);
await waitForNextUpdate();
expect(result.current.response).toBe(null);
expect(result.current.error).toBe("fake error");
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe(null);
});
it("error is updated after an exception", async () => {
const upstreams = [{ uri: "http://localhost/error", options: {} }];
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAny(upstreams)
);
expect(result.current.response).toBe(null);
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(true);
expect(result.current.responseURI).toBe(null);
await waitForNextUpdate();
expect(result.current.response).toBe(null);
expect(result.current.error).toBe("failed to fetch");
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe(null);
});
it("doesn't update response after cleanup", async () => {
fetchMock.mock(
"http://localhost/slow/ok",
new Promise((res) => setTimeout(() => res("ok"), 1000))
);
const upstreams = [{ uri: "http://localhost/slow/ok", options: {} }];
const Component = () => {
const { response, error, inProgress } = useFetchAny(upstreams);
return (
<span>
<span>{response}</span>
<span>{error}</span>
<span>{inProgress}</span>
</span>
);
};
const tree = mount(<Component />);
tree.unmount();
await fetchMock.flush(true);
});
it("doesn't update error on 500 response after cleanup", async () => {
fetchMock.mock("http://localhost/slow/500", {
delay: 1000,
status: 500,
body: "error",
});
const upstreams = [{ uri: "http://localhost/slow/500", options: {} }];
const Component = () => {
const { response, error, inProgress } = useFetchAny(upstreams);
return (
<span>
<span>{response}</span>
<span>{error}</span>
<span>{inProgress}</span>
</span>
);
};
const tree = mount(<Component />);
tree.unmount();
await fetchMock.flush(true);
});
it("doesn't update error on failed response after cleanup", async () => {
fetchMock.mock("http://localhost/slow/error", {
delay: 1000,
throws: new TypeError("failed to fetch"),
});
const upstreams = [{ uri: "http://localhost/slow/error", options: {} }];
const Component = () => {
const { response, error, inProgress } = useFetchAny(upstreams);
return (
<span>
<span>{response}</span>
<span>{error}</span>
<span>{inProgress}</span>
</span>
);
};
const tree = mount(<Component />);
tree.unmount();
await fetchMock.flush(true);
});
it("doesn't retry on success", async () => {
const upstreams = [
{ uri: "http://localhost/ok", options: {} },
{ uri: "http://localhost/500", options: {} },
{ uri: "http://localhost/error", options: {} },
];
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAny(upstreams)
);
await waitForNextUpdate();
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.calls()[0][0]).toBe("http://localhost/ok");
expect(result.current.response).toBe("body ok");
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe("http://localhost/ok");
});
it("tries all URIs from the list on failures", async () => {
const upstreams = [
{ uri: "http://localhost/500", options: {} },
{ uri: "http://localhost/error", options: {} },
{ uri: "http://localhost/ok", options: {} },
];
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAny(upstreams)
);
await waitForNextUpdate();
expect(fetchMock.calls()).toHaveLength(3);
expect(fetchMock.calls()[0][0]).toBe("http://localhost/500");
expect(fetchMock.calls()[1][0]).toBe("http://localhost/error");
expect(fetchMock.calls()[2][0]).toBe("http://localhost/ok");
expect(result.current.response).toBe("body ok");
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe("http://localhost/ok");
});
it("first working URI sets the response", async () => {
const upstreams = [
{ uri: "http://localhost/500", options: {} },
{ uri: "http://localhost/ok/json", options: {} },
{ uri: "http://localhost/error", options: {} },
];
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAny(upstreams)
);
await waitForNextUpdate();
expect(fetchMock.calls()).toHaveLength(2);
expect(fetchMock.calls()[0][0]).toBe("http://localhost/500");
expect(fetchMock.calls()[1][0]).toBe("http://localhost/ok/json");
expect(result.current.response).toMatchObject({ status: "ok" });
expect(result.current.error).toBe(null);
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe("http://localhost/ok/json");
});
it("uses last error in the result", async () => {
const upstreams = [
{ uri: "http://localhost/error", options: {} },
{ uri: "http://localhost/500", options: {} },
];
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAny(upstreams)
);
await waitForNextUpdate();
expect(result.current.response).toBe(null);
expect(result.current.error).toBe("fake error");
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe(null);
});
});