From b09dd6f8c9b36b89ac36f64b3fb8280a9ef0017c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Sat, 6 Jun 2020 19:52:20 +0100 Subject: [PATCH] feat(ui): add a fetch hook for sending requests to any of cluster members --- ui/src/Hooks/useFetchAny.js | 76 +++++++ ui/src/Hooks/useFetchAny.test.js | 326 +++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 ui/src/Hooks/useFetchAny.js create mode 100644 ui/src/Hooks/useFetchAny.test.js diff --git a/ui/src/Hooks/useFetchAny.js b/ui/src/Hooks/useFetchAny.js new file mode 100644 index 000000000..26e3df806 --- /dev/null +++ b/ui/src/Hooks/useFetchAny.js @@ -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 }; diff --git a/ui/src/Hooks/useFetchAny.test.js b/ui/src/Hooks/useFetchAny.test.js new file mode 100644 index 000000000..46925d40e --- /dev/null +++ b/ui/src/Hooks/useFetchAny.test.js @@ -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 ( + + {response} + {error} + {inProgress} + + ); + }; + + const tree = mount(); + 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 ( + + {response} + {error} + {inProgress} + + ); + }; + + const tree = mount(); + 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 ( + + {response} + {error} + {inProgress} + + ); + }; + + const tree = mount(); + 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); + }); +});