AndrewWalsh/at-your-service

View on GitHub
demo/src/Requester.tsx

Summary

Maintainability
F
4 days
Test Coverage
import { useState, useEffect, MouseEvent, useMemo } from "react";
import {
  Box,
  Select,
  Input,
  Button,
  Heading,
  Text,
  Code,
  Kbd,
  useToast,
  InputGroup,
  InputLeftAddon,
} from "@chakra-ui/react";
import { setupWorker, rest, SetupWorker } from "msw";
import isJSON from "validator/es/lib/isJSON";
import isValidHostName from "is-valid-hostname";

import ace from "ace-builds/src-noconflict/ace";
ace.config.set(
  "basePath",
  "https://cdn.jsdelivr.net/npm/ace-builds@1.4.3/src-noconflict/"
);
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/theme-monokai";
import "ace-builds/src-noconflict/mode-json";

import { COLOR_SECONDARY } from "./constants";
import initMSW from "./init-msw";
import KeyValue, { KV } from "./KeyValue";
import LatencySlider from "./LatencySlider";

const reqEditorDefaultVal = {
  id: 42,
};

const resEditorDefaultVal = {
  text: "simulate some requests and check out the tool",
  number: 1,
  null: null,
  array: [1, 2, 3],
  object: {
    nested: {
      hello: "world",
    },
  },
};

const prettyPrintJSON = (json: {}) => JSON.stringify(json, null, 2);

function Requester() {
  const worker = useMemo<SetupWorker>(() => setupWorker(), []);
  const [reqEditorVal, setReqEditorVal] = useState(
    prettyPrintJSON(reqEditorDefaultVal)
  );
  const [resEditorVal, setResEditorVal] = useState(
    prettyPrintJSON(resEditorDefaultVal)
  );
  const [host, setHost] = useState("https://example.com");
  const [pathname, setPathname] = useState("/api");
  const [method, setMethod] = useState("POST");
  const [status, setStatus] = useState("200");
  const [latency, setLatency] = useState(50);
  const toast = useToast({ position: "top" });

  const [requestHeaders, setRequestHeaders] = useState<KV>([]);
  const [responseHeaders, setResponseHeaders] = useState<KV>([]);
  const [queryParameters, setQueryParameters] = useState<KV>([]);

  // Start the worker with a dummy handler
  useEffect(() => {
    initMSW(worker);
  }, []);

  const onClickMockRequest = async (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    if (!worker) {
      return;
    }
    worker.resetHandlers();
    const handler = rest[method.toLowerCase()] as typeof rest.get;
    worker.use(
      handler(host + pathname, (req, res, ctx) => {
        return res(
          ctx.delay(latency),
          ctx.status(Number(status), "OK"),
          ctx.json(JSON.parse(resEditorVal)),
          ctx.set(Object.fromEntries(responseHeaders))
        );
      })
    );
    const params = showReqEditor
      ? { method, headers: requestHeaders, body: reqEditorVal }
      : { method, headers: requestHeaders };

    const urlParams = new URLSearchParams(Object.fromEntries(queryParameters));
    let query = urlParams.toString();
    query = query ? `?${query}` : "";

    // the fetch promise will never resolve when using msw
    // if it fails, then the service worker has idled/terminated
    // the reload is not ideal but will reactivate it
    try {
      toast({
        title: "API request mocked",
        description: `${method} ${host}${pathname}${query} -> ${status}`,
        status: "success",
        duration: 2000,
        isClosable: true,
      });
      await fetch(`${host}${pathname}${query}`, params);
    } catch {
      window.location.reload();
    }

  };

  const showReqEditor = method !== "GET" && method !== "DELETE";

  const reqEditorIsValid = useMemo(() => {
    if (!showReqEditor) {
      return true;
    }
    try {
      if (!isJSON(reqEditorVal)) {
        return false;
      }
      return true;
    } catch {
      return false;
    }
  }, [reqEditorVal]);

  const resEditorIsValid = useMemo(() => {
    try {
      if (!isJSON(resEditorVal)) {
        return false;
      }
      return true;
    } catch {
      return false;
    }
  }, [resEditorVal]);

  const hostIsValid = useMemo(() => {
    try {
      const url = new URL(host + pathname);
      if (url.protocol !== "https:" && url.protocol !== "http:") {
        return false;
      }
      const valid = isValidHostName(url.host);
      return valid;
    } catch {
      return false;
    }
  }, [host, pathname]);

  const pathnameIsValid = useMemo(() => {
    try {
      const url = new URL(host + pathname);
      return url.pathname === pathname;
    } catch {
      return false;
    }
  }, [pathname]);

  return (
    <Box
      borderRadius="1px"
      display="flex"
      flexFlow="column nowrap"
      boxSizing="border-box"
      gap={2}
    >
      <Box display="flex" flexFlow="row wrap" flex="1" height="100%" gap={4}>
        {showReqEditor && (
          <Box
            flex="1"
            minWidth="300px"
            boxSizing="border-box"
            role="dialog"
            aria-labelledby="dialog1Title"
            minHeight="384px"
          >
            <Box height="32px" display="flex" alignItems="center">
              <Heading
                as="label"
                size="md"
                id="dialog1Title"
                htmlFor="ace-editor-req-bdy"
              >
                Request Body
              </Heading>
            </Box>
            <AceEditor
              mode="json"
              theme="monokai"
              width="100%"
              height="calc(100% - 32px)"
              name="ace-editor-req-bdy"
              style={
                reqEditorIsValid
                  ? undefined
                  : {
                      outline: "2px solid #e53e3e",
                      boxShadow: "0 0 0 1px #e53e3e",
                      borderRadius: "0.375rem",
                    }
              }
              editorProps={{ $blockScrolling: true }}
              value={reqEditorVal}
              onChange={setReqEditorVal}
              aria-invalid={!reqEditorIsValid}
              onBlur={() => {
                try {
                  const val = prettyPrintJSON(JSON.parse(reqEditorVal));
                  setReqEditorVal(val);
                } catch {}
              }}
            />
          </Box>
        )}
        <Box
          flex="1"
          minWidth="300px"
          boxSizing="border-box"
          role="dialog"
          aria-labelledby="dialog2Title"
          minHeight="384px"
        >
          <Box height="32px" display="flex" alignItems="center">
            <Heading
              as="label"
              size="md"
              id="dialog2Title"
              htmlFor="ace-editor-res-bdy"
            >
              Response Body
            </Heading>
          </Box>
          <AceEditor
            mode="json"
            theme="monokai"
            width="100%"
            height="calc(100% - 32px)"
            name="ace-editor-res-bdy"
            style={
              resEditorIsValid
                ? undefined
                : {
                    outline: "2px solid #e53e3e",
                    boxShadow: "0 0 0 1px #e53e3e",
                    borderRadius: "0.375rem",
                  }
            }
            editorProps={{ $blockScrolling: true }}
            value={resEditorVal}
            onChange={setResEditorVal}
            aria-invalid={!resEditorIsValid}
            onBlur={() => {
              try {
                const val = prettyPrintJSON(JSON.parse(resEditorVal));
                setResEditorVal(val);
              } catch {}
            }}
          />
        </Box>
      </Box>

      <Box
        display="flex"
        flexFlow="column"
        as="form"
        gap={4}
        marginTop="8px"
        overflow="visible"
      >
        <Button
          bg={COLOR_SECONDARY}
          colorScheme="blue"
          onClick={onClickMockRequest}
          disabled={
            !reqEditorIsValid ||
            !resEditorIsValid ||
            !hostIsValid ||
            !pathnameIsValid
          }
          type="submit"
        >
          Mock Network Request
        </Button>

        <Box display="flex" flexFlow="row wrap" justifyContent="space-around">
          <Box
            paddingTop="32px"
            display="flex"
            alignItems="flex-start"
            justifyContent="flex-start"
            flexFlow="column nowrap"
            gap={4}
          >
            <InputGroup>
              <InputLeftAddon children="Method" width="100px" />
              <Select
                value={method}
                minWidth="211px"
                maxWidth="211px"
                onChange={(e) => setMethod(e.target.value)}
              >
                <option value="GET">GET</option>
                <option value="PUT">PUT</option>
                <option value="POST">POST</option>
                <option value="DELETE">DELETE</option>
                <option value="PATCH">PATCH</option>
              </Select>
            </InputGroup>

            <InputGroup>
              <InputLeftAddon children="Host" width="100px" />
              <Input
                value={host}
                minWidth="211px"
                maxWidth="211px"
                onChange={(e) => setHost(e.target.value)}
                isInvalid={!hostIsValid}
                placeholder="E.g. http://example.com"
              />
            </InputGroup>

            <InputGroup>
              <InputLeftAddon children="Pathname" width="100px" />
              <Input
                value={pathname}
                onChange={(e) => setPathname(e.target.value)}
                minWidth="211px"
                maxWidth="211px"
                isInvalid={!pathnameIsValid}
                isDisabled={!hostIsValid}
                placeholder="E.g. /api/v1/users"
              />
            </InputGroup>

            <InputGroup>
              <InputLeftAddon children="Status" width="100px" />
              <Select
                minWidth="211px"
                maxWidth="211px"
                value={status}
                onChange={(e) => setStatus(e.target.value)}
              >
                <option value="200">200</option>
                <option value="400">400</option>
              </Select>
            </InputGroup>

            <InputGroup>
              <InputLeftAddon children="Latency" width="100px" />
              <Box
                width="100%"
                display="flex"
                alignItems="center"
                margin="0 16px"
              >
                <LatencySlider value={latency} setValue={setLatency} />
              </Box>
            </InputGroup>
          </Box>

          <Box width="300px" paddingTop="32px">
            <KeyValue title="Request headers" onChange={setRequestHeaders} />
          </Box>

          <Box width="300px" paddingTop="32px">
            <KeyValue title="Response headers" onChange={setResponseHeaders} />
          </Box>

          <Box width="300px" paddingTop="32px">
            <KeyValue title="Query parameters" onChange={setQueryParameters} />
          </Box>
        </Box>

        <Box
          display="flex"
          alignItems="center"
          justifyContent="center"
          flex="1"
          marginTop="5vh"
        >
          <Text padding="1em" textAlign="center">
            Simulate an API request on the browser
            <br />
            <br />
            Requests are visible in the <Code>Network</Code> section of your
            browser's developer tools
            <br />
            <br />
            Open <Kbd>at-your-service</Kbd> by clicking <Code>Open</Code> in the
            bottom left corner of the screen
          </Text>
        </Box>
      </Box>
    </Box>
  );
}

export default Requester;