Skip to content

Commit

Permalink
[Issue #1294]: API setup part 2 (#1386)
Browse files Browse the repository at this point in the history
## Summary
Fixes #1294 

- Working API calls local-to-local
- MockSearchFetcher fetches json file
- Add CORS to API
- Hardcode public auth token 
- Fix tests
  • Loading branch information
rylew1 authored Mar 5, 2024
1 parent bdb4516 commit 1abd0c3
Show file tree
Hide file tree
Showing 15 changed files with 276 additions and 132 deletions.
39 changes: 16 additions & 23 deletions api/poetry.lock

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

1 change: 1 addition & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ marshmallow = "^3.20.1"
gunicorn = "^21.2.0"
psycopg = {extras = ["binary"], version = "^3.1.10"}
pydantic-settings = "^2.0.3"
flask-cors = "^4.0.0"

[tool.poetry.group.dev.dependencies]
black = "^23.9.1"
Expand Down
2 changes: 2 additions & 0 deletions api/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any, Tuple

from apiflask import APIFlask, exceptions
from flask_cors import CORS
from pydantic import Field

import src.adapters.db as db
Expand Down Expand Up @@ -51,6 +52,7 @@ def create_app() -> APIFlask:

feature_flag_config.initialize()

CORS(app)
configure_app(app)
register_blueprints(app)
register_index(app)
Expand Down
4 changes: 4 additions & 0 deletions frontend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ SENDY_API_URL=
SENDY_LIST_ID=

NEXT_PUBLIC_API_URL=http://localhost:8080

# Hardcode public auth key for local to local calls
# This is also hardcoded and checked-in on the API
NEXT_PUBLIC_LOCAL_AUTH_TOKEN=LOCAL_AUTH_12345678
53 changes: 33 additions & 20 deletions frontend/src/api/BaseApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,44 +18,55 @@ export interface HeadersDict {
}

export default abstract class BaseApi {
/**
* Root path of API resource without leading slash.
*/
// Root path of API resource without leading slash.
abstract get basePath(): string;

/**
* Namespace representing the API resource.
*/
// API version
get version() {
return "v0.1";
}

// 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 {};
// Configuration of headers to send with all requests
// Can include feature flags in child classes
get headers(): HeadersDict {
const headers: HeadersDict = {};
if (process.env.NEXT_PUBLIC_LOCAL_AUTH_TOKEN) {
headers["X-AUTH"] = process.env.NEXT_PUBLIC_LOCAL_AUTH_TOKEN;
}
return headers;
}

/**
* Send an API request.
*/
async request<TResponseData>(
method: ApiMethod,
subPath = "",
basePath: string,
namespace: string,
subPath: string,
body?: JSONRequestBody,
options: {
additionalHeaders?: HeadersDict;
} = {},
) {
const { additionalHeaders = {} } = options;
const url = createRequestUrl(method, this.basePath, subPath, body);
const url = createRequestUrl(
method,
basePath,
this.version,
namespace,
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,
Expand All @@ -74,7 +85,6 @@ export default abstract class BaseApi {
) {
let response: Response;
let responseBody: ApiResponseBody<TResponseData>;

try {
response = await fetch(url, fetchOptions);
responseBody = (await response.json()) as ApiResponseBody<TResponseData>;
Expand Down Expand Up @@ -110,13 +120,16 @@ export default abstract class BaseApi {
export function createRequestUrl(
method: ApiMethod,
basePath: string,
version: string,
namespace: 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("/");

// Remove leading slash
const cleanedPaths = compact([basePath, version, namespace, subPath]).map(
removeLeadingSlash,
);
let url = [...cleanedPaths].join("/");
if (method === "GET" && body && !(body instanceof FormData)) {
// Append query string to URL
const searchBody: { [key: string]: string } = {};
Expand Down
33 changes: 23 additions & 10 deletions frontend/src/api/SearchOpportunityAPI.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,42 @@
import BaseApi, { JSONRequestBody } from "./BaseApi";

export interface SearchResponseData {
opportunities: unknown[];
}
import { Opportunity } from "../types/searchTypes";

export type SearchResponseData = Opportunity[];

export default class SearchOpportunityAPI extends BaseApi {
get basePath(): string {
return "search/opportunities";
return process.env.NEXT_PUBLIC_API_URL || "";
}

get namespace(): string {
return "searchOpportunities";
return "opportunities";
}

get headers() {
return {};
const baseHeaders = super.headers;
const searchHeaders = {};
return { ...baseHeaders, ...searchHeaders };
}

async getSearchOpportunities(queryParams?: JSONRequestBody) {
const subPath = "";
async searchOpportunities(queryParams?: JSONRequestBody) {
const requestBody = {
pagination: {
order_by: "opportunity_id",
page_offset: 1,
page_size: 25,
sort_direction: "ascending",
},
...queryParams,
};

const subPath = "search";
const response = await this.request<SearchResponseData>(
"GET",
"POST",
this.basePath,
this.namespace,
subPath,
queryParams,
requestBody,
);

return response;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/mock/APIMockResponse.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"agency": "US-ABC",
"applicant_types": ["state_governments", "county_governments"],
"category": "discretionary",
"category": "<===========THIS IS MOCK DATA==============>",
"category_explanation": null,
"created_at": "2024-02-14T20:29:26.960670+00:00",
"funding_categories": ["agriculture", "recovery_act"],
Expand Down
19 changes: 10 additions & 9 deletions frontend/src/app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import {

import { APISearchFetcher } from "../../services/searchfetcher/APISearchFetcher";
import { MockSearchFetcher } from "../../services/searchfetcher/MockSearchFetcher";
import { Opportunity } from "../../types/searchTypes";
import PageNotFound from "../../pages/404";
import { SearchResponseData } from "../../api/SearchOpportunityAPI";
import { useFeatureFlags } from "src/hooks/useFeatureFlags";

const useMockData = true;
const useMockData = false;
const searchFetcher: SearchFetcher = useMockData
? new MockSearchFetcher()
: new APISearchFetcher();
Expand All @@ -22,13 +22,9 @@ const searchFetcher: SearchFetcher = useMockData
// locale: string;
// }

// interface SearchProps {
// initialOpportunities: Opportunity[];
// }

export default function Search() {
const { featureFlagsManager, mounted } = useFeatureFlags();
const [searchResults, setSearchResults] = useState<Opportunity[]>([]);
const [searchResults, setSearchResults] = useState<SearchResponseData>([]);

const handleButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
Expand All @@ -48,10 +44,15 @@ export default function Search() {
return (
<>
<button onClick={handleButtonClick}>Update Results</button>
{searchFetcher instanceof APISearchFetcher ? (
<p>Live API</p>
) : (
<p>Mock Call</p>
)}
<ul>
{searchResults.map((opportunity) => (
<li key={opportunity.id}>
{opportunity.id}, {opportunity.title}
<li key={opportunity.opportunity_id}>
{opportunity.category}, {opportunity.opportunity_title}
</li>
))}
</ul>
Expand Down
27 changes: 16 additions & 11 deletions frontend/src/services/searchfetcher/APISearchFetcher.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { Opportunity } from "../../types/searchTypes";
import { SearchFetcher } from "./SearchFetcher";
import SearchOpportunityAPI, {
SearchResponseData,
} from "../../api/SearchOpportunityAPI";

// TODO: Just a placeholder URL to display some data while we build search
const URL = "https://jsonplaceholder.typicode.com/posts";
import { SearchFetcher } from "./SearchFetcher";

// TODO: call BaseApi or extension to make the actual call
export class APISearchFetcher extends SearchFetcher {
async fetchOpportunities(): Promise<Opportunity[]> {
private searchApi: SearchOpportunityAPI;

constructor() {
super();
this.searchApi = new SearchOpportunityAPI();
}

async fetchOpportunities(): Promise<SearchResponseData> {
try {
const response = await fetch(URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
const response = await this.searchApi.searchOpportunities();
if (!response.data) {
throw new Error(`No data returned from API`);
}
const data: Opportunity[] = (await response.json()) as Opportunity[];
return data;
return response.data;
} catch (error) {
console.error("Error fetching opportunities:", error);
throw error;
Expand Down
Loading

0 comments on commit 1abd0c3

Please sign in to comment.