Skip to content

Commit

Permalink
[Issue #1282]: Setup architecture for API calls with real and mock da…
Browse files Browse the repository at this point in the history
…ta - part 1a (#1263)

## Summary
Fixes #1282 

## Changes proposed
- setup API call structure
  • Loading branch information
rylew1 authored Feb 23, 2024
1 parent 312515f commit 2efd55d
Show file tree
Hide file tree
Showing 13 changed files with 473 additions and 9 deletions.
7 changes: 5 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
151 changes: 151 additions & 0 deletions frontend/src/api/BaseApi.ts
Original file line number Diff line number Diff line change
@@ -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<TResponseData> {
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<TResponseData>(
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<TResponseData>(url, {
body: method === "GET" || !body ? null : createRequestBody(body),
headers,
method,
});

return response;
}

/**
* Send a request and handle the response
*/
private async sendRequest<TResponseData>(
url: string,
fetchOptions: RequestInit
) {
let response: Response;
let responseBody: ApiResponseBody<TResponseData>;

try {
response = await fetch(url, fetchOptions);
responseBody = (await response.json()) as ApiResponseBody<TResponseData>;
} 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);
}
31 changes: 31 additions & 0 deletions frontend/src/api/SearchOpportunityAPI.ts
Original file line number Diff line number Diff line change
@@ -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<SearchResponseData>(
"GET",
subPath,
queryParams
);

return response;
}
}
57 changes: 53 additions & 4 deletions frontend/src/pages/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchProps> = ({ initialOpportunities = [] }) => {
const { featureFlagsManager, mounted } = useFeatureFlags();
const [searchResults, setSearchResults] =
useState<Opportunity[]>(initialOpportunities);

const handleButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
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 <PageNotFound />;
}

return <>Search Boilerplate</>;
return (
<>
<button onClick={handleButtonClick}>Update Results</button>
<ul>
{searchResults.map((opportunity) => (
<li key={opportunity.id}>
{opportunity.id}, {opportunity.title}
</li>
))}
</ul>
</>
);
};

// 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;
22 changes: 22 additions & 0 deletions frontend/src/services/searchfetcher/APISearchFetcher.ts
Original file line number Diff line number Diff line change
@@ -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<Opportunity[]> {
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;
}
}
}
41 changes: 41 additions & 0 deletions frontend/src/services/searchfetcher/MockSearchFetcher.ts
Original file line number Diff line number Diff line change
@@ -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<Opportunity[]> {
return await new Promise((resolve) => {
// Resolve mock data file with simulated delay
setTimeout(() => {
resolve(MOCKOPPORTUNITIES);
}, 500);
});
}
}
16 changes: 16 additions & 0 deletions frontend/src/services/searchfetcher/SearchFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Opportunity } from "../../types/searchTypes";

export abstract class SearchFetcher {
abstract fetchOpportunities(): Promise<Opportunity[]>;
}

export async function fetchSearchOpportunities(
searchFetcher: SearchFetcher
): Promise<Opportunity[]> {
try {
return await searchFetcher.fetchOpportunities();
} catch (error) {
console.error("Failed to fetch opportunities:", error);
return [];
}
}
6 changes: 6 additions & 0 deletions frontend/src/types/searchTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface Opportunity {
userId: number;
id: number;
title: string;
body: string;
}
Loading

0 comments on commit 2efd55d

Please sign in to comment.