From 2efd55d0a308f59114d5a357ad554692e43dc36b Mon Sep 17 00:00:00 2001 From: Ryan Lewis <93001277+rylew1@users.noreply.github.com> Date: Fri, 23 Feb 2024 12:53:07 -0800 Subject: [PATCH] [Issue #1282]: Setup architecture for API calls with real and mock data - part 1a (#1263) ## Summary Fixes #1282 ## Changes proposed - setup API call structure --- frontend/package-lock.json | 7 +- frontend/package.json | 1 + frontend/src/api/BaseApi.ts | 151 ++++++++++++++++++ frontend/src/api/SearchOpportunityAPI.ts | 31 ++++ frontend/src/pages/search.tsx | 57 ++++++- .../searchfetcher/APISearchFetcher.ts | 22 +++ .../searchfetcher/MockSearchFetcher.ts | 41 +++++ .../services/searchfetcher/SearchFetcher.ts | 16 ++ frontend/src/types/searchTypes.ts | 6 + frontend/tests/api/BaseApi.test.ts | 45 ++++++ .../tests/api/SearchOpportunityApi.test.ts | 54 +++++++ frontend/tests/jest.setup.js | 9 ++ frontend/tests/pages/search.test.tsx | 42 ++++- 13 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 frontend/src/api/BaseApi.ts create mode 100644 frontend/src/api/SearchOpportunityAPI.ts create mode 100644 frontend/src/services/searchfetcher/APISearchFetcher.ts create mode 100644 frontend/src/services/searchfetcher/MockSearchFetcher.ts create mode 100644 frontend/src/services/searchfetcher/SearchFetcher.ts create mode 100644 frontend/src/types/searchTypes.ts create mode 100644 frontend/tests/api/BaseApi.test.ts create mode 100644 frontend/tests/api/SearchOpportunityApi.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f087bfe0b..1b2570d74 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@uswds/uswds": "^3.6.0", "i18next": "^23.0.0", "js-cookie": "^3.0.5", + "lodash": "^4.17.21", "next": "^13.5.2", "next-i18next": "^15.0.0", "react": "^18.2.0", @@ -62,6 +63,9 @@ "storybook-react-i18next": "^2.0.6", "style-loader": "^3.3.2", "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -17697,8 +17701,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.debounce": { "version": "4.0.8", diff --git a/frontend/package.json b/frontend/package.json index 94319f367..17da166cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@uswds/uswds": "^3.6.0", "i18next": "^23.0.0", "js-cookie": "^3.0.5", + "lodash": "^4.17.21", "next": "^13.5.2", "next-i18next": "^15.0.0", "react": "^18.2.0", diff --git a/frontend/src/api/BaseApi.ts b/frontend/src/api/BaseApi.ts new file mode 100644 index 000000000..08c8cfc0f --- /dev/null +++ b/frontend/src/api/BaseApi.ts @@ -0,0 +1,151 @@ +import { compact } from "lodash"; + +export type ApiMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT"; +export interface JSONRequestBody { + [key: string]: unknown; +} + +export interface ApiResponseBody { + message: string; + data: TResponseData; + status_code: number; + errors?: unknown[]; // TODO: define error and warning Issue type + warnings?: unknown[]; +} + +export interface HeadersDict { + [header: string]: string; +} + +export default abstract class BaseApi { + /** + * Root path of API resource without leading slash. + */ + abstract get basePath(): string; + + /** + * Namespace representing the API resource. + */ + abstract get namespace(): string; + + /** + * Configuration of headers to send with all requests + * Can include feature flags in child classes + */ + get headers() { + return {}; + } + + /** + * Send an API request. + */ + async request( + method: ApiMethod, + subPath = "", + body?: JSONRequestBody, + options: { + additionalHeaders?: HeadersDict; + } = {} + ) { + const { additionalHeaders = {} } = options; + const url = createRequestUrl(method, this.basePath, subPath, body); + const headers: HeadersDict = { + ...additionalHeaders, + ...this.headers, + }; + + headers["Content-Type"] = "application/json"; + + const response = await this.sendRequest(url, { + body: method === "GET" || !body ? null : createRequestBody(body), + headers, + method, + }); + + return response; + } + + /** + * Send a request and handle the response + */ + private async sendRequest( + url: string, + fetchOptions: RequestInit + ) { + let response: Response; + let responseBody: ApiResponseBody; + + try { + response = await fetch(url, fetchOptions); + responseBody = (await response.json()) as ApiResponseBody; + } catch (error) { + console.log("Network Error encountered => ", error); + throw new Error("Network request failed"); + // TODO: Error management + // throw fetchErrorToNetworkError(error); + } + + const { data, errors, warnings } = responseBody; + if (!response.ok) { + console.log( + "Not OK Response => ", + response, + errors, + this.namespace, + data + ); + + throw new Error("Not OK response received"); + // TODO: Error management + // handleNotOkResponse(response, errors, this.namespace, data); + } + + return { + data, + warnings, + }; + } +} + +export function createRequestUrl( + method: ApiMethod, + basePath: string, + subPath: string, + body?: JSONRequestBody +) { + // Remove leading slash from apiPath if it has one + const cleanedPaths = compact([basePath, subPath]).map(removeLeadingSlash); + let url = [process.env.apiUrl, ...cleanedPaths].join("/"); + + if (method === "GET" && body && !(body instanceof FormData)) { + // Append query string to URL + const searchBody: { [key: string]: string } = {}; + Object.entries(body).forEach(([key, value]) => { + const stringValue = + typeof value === "string" ? value : JSON.stringify(value); + searchBody[key] = stringValue; + }); + + const params = new URLSearchParams(searchBody).toString(); + url = `${url}?${params}`; + } + return url; +} + +/** + * Remove leading slash + */ +function removeLeadingSlash(path: string) { + return path.replace(/^\//, ""); +} + +/** + * Transform the request body into a format that fetch expects + */ +function createRequestBody(payload?: JSONRequestBody): XMLHttpRequestBodyInit { + if (payload instanceof FormData) { + return payload; + } + + return JSON.stringify(payload); +} diff --git a/frontend/src/api/SearchOpportunityAPI.ts b/frontend/src/api/SearchOpportunityAPI.ts new file mode 100644 index 000000000..406da5b1c --- /dev/null +++ b/frontend/src/api/SearchOpportunityAPI.ts @@ -0,0 +1,31 @@ +import BaseApi, { JSONRequestBody } from "./BaseApi"; + +export interface SearchResponseData { + opportunities: unknown[]; +} + +export default class SearchOpportunityAPI extends BaseApi { + get basePath(): string { + return "search/opportunities"; + } + + get namespace(): string { + return "searchOpportunities"; + } + + get headers() { + return {}; + } + + async getSearchOpportunities(queryParams?: JSONRequestBody) { + const subPath = ""; + + const response = await this.request( + "GET", + subPath, + queryParams + ); + + return response; + } +} diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index dd84a9473..a5a5c71b6 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -2,25 +2,74 @@ import type { GetStaticProps, NextPage } from "next"; import { useFeatureFlags } from "src/hooks/useFeatureFlags"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import React, { useState } from "react"; +import { APISearchFetcher } from "../services/searchfetcher/APISearchFetcher"; +import { MockSearchFetcher } from "../services/searchfetcher/MockSearchFetcher"; +import { + fetchSearchOpportunities, + SearchFetcher, +} from "../services/searchfetcher/SearchFetcher"; +import { Opportunity } from "../types/searchTypes"; import PageNotFound from "./404"; -const Search: NextPage = () => { +const useMockData = true; +const searchFetcher: SearchFetcher = useMockData + ? new MockSearchFetcher() + : new APISearchFetcher(); + +interface SearchProps { + initialOpportunities: Opportunity[]; +} + +const Search: NextPage = ({ initialOpportunities = [] }) => { const { featureFlagsManager, mounted } = useFeatureFlags(); + const [searchResults, setSearchResults] = + useState(initialOpportunities); + + const handleButtonClick = (event: React.MouseEvent) => { + event.preventDefault(); + performSearch().catch((e) => console.log(e)); + }; + + const performSearch = async () => { + const opportunities = await fetchSearchOpportunities(searchFetcher); + setSearchResults(opportunities); + }; if (!mounted) return null; if (!featureFlagsManager.isFeatureEnabled("showSearchV0")) { return ; } - return <>Search Boilerplate; + return ( + <> + +
    + {searchResults.map((opportunity) => ( +
  • + {opportunity.id}, {opportunity.title} +
  • + ))} +
+ + ); }; -// Change this to GetServerSideProps if you're using server-side rendering export const getStaticProps: GetStaticProps = async ({ locale }) => { + // Always pre-render the initial search results + // TODO (1189): If the URL has query params - they will need to be included in the search here + const initialOpportunities: Opportunity[] = await fetchSearchOpportunities( + searchFetcher + ); const translations = await serverSideTranslations(locale ?? "en"); - return { props: { ...translations } }; + return { + props: { + initialOpportunities, + ...translations, + }, + }; }; export default Search; diff --git a/frontend/src/services/searchfetcher/APISearchFetcher.ts b/frontend/src/services/searchfetcher/APISearchFetcher.ts new file mode 100644 index 000000000..f545184e7 --- /dev/null +++ b/frontend/src/services/searchfetcher/APISearchFetcher.ts @@ -0,0 +1,22 @@ +import { Opportunity } from "../../types/searchTypes"; +import { SearchFetcher } from "./SearchFetcher"; + +// TODO: Just a placeholder URL to display some data while we build search +const URL = "https://jsonplaceholder.typicode.com/posts"; + +// TODO: call BaseApi or extension to make the actual call +export class APISearchFetcher extends SearchFetcher { + async fetchOpportunities(): Promise { + try { + const response = await fetch(URL); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: Opportunity[] = (await response.json()) as Opportunity[]; + return data; + } catch (error) { + console.error("Error fetching opportunities:", error); + throw error; + } + } +} diff --git a/frontend/src/services/searchfetcher/MockSearchFetcher.ts b/frontend/src/services/searchfetcher/MockSearchFetcher.ts new file mode 100644 index 000000000..59e4c8c6e --- /dev/null +++ b/frontend/src/services/searchfetcher/MockSearchFetcher.ts @@ -0,0 +1,41 @@ +import { Opportunity } from "../../types/searchTypes"; +import { SearchFetcher } from "./SearchFetcher"; + +export const MOCKOPPORTUNITIES: Opportunity[] = [ + { + userId: 1, + id: 1, + title: + "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", + }, + { + userId: 1, + id: 2, + title: "qui est esse", + body: "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla", + }, + { + userId: 1, + id: 3, + title: "ea molestias quasi exercitationem repellat qui ipsa sit aut", + body: "et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut", + }, + { + userId: 1, + id: 4, + title: "eum et est occaecati", + body: "ullam et saepe reiciendis voluptatem adipisci\nsit amet autem assumenda provident rerum culpa\nquis hic commodi nesciunt rem tenetur doloremque ipsam iure\nquis sunt voluptatem rerum illo velit", + }, +]; + +export class MockSearchFetcher extends SearchFetcher { + async fetchOpportunities(): Promise { + return await new Promise((resolve) => { + // Resolve mock data file with simulated delay + setTimeout(() => { + resolve(MOCKOPPORTUNITIES); + }, 500); + }); + } +} diff --git a/frontend/src/services/searchfetcher/SearchFetcher.ts b/frontend/src/services/searchfetcher/SearchFetcher.ts new file mode 100644 index 000000000..b4931ad5a --- /dev/null +++ b/frontend/src/services/searchfetcher/SearchFetcher.ts @@ -0,0 +1,16 @@ +import { Opportunity } from "../../types/searchTypes"; + +export abstract class SearchFetcher { + abstract fetchOpportunities(): Promise; +} + +export async function fetchSearchOpportunities( + searchFetcher: SearchFetcher +): Promise { + try { + return await searchFetcher.fetchOpportunities(); + } catch (error) { + console.error("Failed to fetch opportunities:", error); + return []; + } +} diff --git a/frontend/src/types/searchTypes.ts b/frontend/src/types/searchTypes.ts new file mode 100644 index 000000000..d8dcd8abe --- /dev/null +++ b/frontend/src/types/searchTypes.ts @@ -0,0 +1,6 @@ +export interface Opportunity { + userId: number; + id: number; + title: string; + body: string; +} diff --git a/frontend/tests/api/BaseApi.test.ts b/frontend/tests/api/BaseApi.test.ts new file mode 100644 index 000000000..61d497a72 --- /dev/null +++ b/frontend/tests/api/BaseApi.test.ts @@ -0,0 +1,45 @@ +import BaseApi, { ApiMethod } from "../../src/api/BaseApi"; + +// Define a concrete implementation of BaseApi for testing +class TestApi extends BaseApi { + get basePath(): string { + return "api"; + } + + get namespace(): string { + return "test"; + } +} + +describe("BaseApi", () => { + let testApi: TestApi; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ data: [], errors: [], warnings: [] }), + ok: true, + status: 200, + }); + + testApi = new TestApi(); + }); + + it("sends a GET request to the API", async () => { + const method: ApiMethod = "GET"; + const subPath = "endpoint"; + + await testApi.request(method, subPath); + + const expectedHeaders = { + "Content-Type": "application/json", + }; + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method, + headers: expectedHeaders, + }) + ); + }); +}); diff --git a/frontend/tests/api/SearchOpportunityApi.test.ts b/frontend/tests/api/SearchOpportunityApi.test.ts new file mode 100644 index 000000000..2e54edd1e --- /dev/null +++ b/frontend/tests/api/SearchOpportunityApi.test.ts @@ -0,0 +1,54 @@ +import SearchOpportunityAPI from "../../src/api/SearchOpportunityAPI"; + +const mockFetch = ({ + response = { data: { opportunities: [] }, errors: [], warnings: [] }, + ok = true, + status = 200, +}) => { + return jest.fn().mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce(response), + ok, + status, + }); +}; + +describe("SearchOpportunityAPI", () => { + let searchApi: SearchOpportunityAPI; + const baseRequestHeaders = { + "Content-Type": "application/json", + }; + + beforeEach(() => { + jest.resetAllMocks(); + + searchApi = new SearchOpportunityAPI(); + }); + + describe("getSearchOpportunities", () => { + beforeEach(() => { + global.fetch = mockFetch({ + response: { + data: { opportunities: [] }, + errors: [], + warnings: [], + }, + }); + }); + + it("sends GET request to search opportunities endpoint with query parameters", async () => { + const queryParams = { keyword: "science" }; + const response = await searchApi.getSearchOpportunities(queryParams); + + expect(fetch).toHaveBeenCalledWith( + `${process.env.apiUrl as string}/search/opportunities?keyword=science`, + { + method: "GET", + headers: baseRequestHeaders, + body: null, + } + ); + expect(response.data).toEqual({ opportunities: [] }); + expect(1).toBe(1); + }); + }); +}); diff --git a/frontend/tests/jest.setup.js b/frontend/tests/jest.setup.js index 4b8d9e263..b62cf6a9b 100644 --- a/frontend/tests/jest.setup.js +++ b/frontend/tests/jest.setup.js @@ -1,4 +1,13 @@ +/** + * @file Sets up the testing framework for each test file + * @see https://jestjs.io/docs/en/configuration#setupfilesafterenv-array + */ require("@testing-library/jest-dom"); const { toHaveNoViolations } = require("jest-axe"); expect.extend(toHaveNoViolations); + +/** + * Mock environment variables + */ +process.env.apiUrl = "http://localhost"; diff --git a/frontend/tests/pages/search.test.tsx b/frontend/tests/pages/search.test.tsx index b2dccb7ea..3bb5dc11f 100644 --- a/frontend/tests/pages/search.test.tsx +++ b/frontend/tests/pages/search.test.tsx @@ -1,12 +1,48 @@ -import { render, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { axe } from "jest-axe"; +import { useFeatureFlags } from "src/hooks/useFeatureFlags"; import Search from "src/pages/search"; +import { MOCKOPPORTUNITIES } from "../../src/services/searchfetcher/MockSearchFetcher"; + +jest.mock("src/hooks/useFeatureFlags"); + +const setFeatureFlag = (flag: string, value: boolean) => { + (useFeatureFlags as jest.Mock).mockReturnValue({ + featureFlagsManager: { + isFeatureEnabled: jest.fn((featureName: string) => + featureName === flag ? value : false + ), + }, + mounted: true, + }); +}; + describe("Search", () => { it("passes accessibility scan", async () => { - const { container } = render(); + setFeatureFlag("showSearchV0", true); + const { container } = render( + + ); const results = await waitFor(() => axe(container)); - expect(results).toHaveNoViolations(); }); + + describe("Search feature flag", () => { + it("renders search results when feature flag is on", () => { + setFeatureFlag("showSearchV0", true); + render(); + expect(screen.getByText(/sunt aut/i)).toBeInTheDocument(); + }); + + it("renders PageNotFound when feature flag is off", () => { + setFeatureFlag("showSearchV0", false); + render(); + expect( + screen.getByText( + /The page you have requested cannot be displayed because it does not exist, has been moved/i + ) + ).toBeInTheDocument(); + }); + }); });