Skip to content
This repository has been archived by the owner on Sep 18, 2024. It is now read-only.

Commit

Permalink
[Issue #16] Connect the API to use the search index (#63)
Browse files Browse the repository at this point in the history
## Summary
Fixes #16

### Time to review: __10 mins__

## Changes proposed
Make the v1 search opportunity endpoint connect to the search index and
return results.

Adjust the structure of the response to be more flexible going forward.

## Context for reviewers
The actual building of the search request / parsing the response is
pretty simple. Other than having to map some field names, that logic is
mostly contained in the builder I made in the prior PR. However, there
is a lot of configuration and other API components that had to be
modified as part of this including:
* Adjusting the API response schema (to better support facet counts)
* Piping through the search client + index alias name configuration.
* A monumental amount of test cases to verify everything is connected /
behavior works in a way we expect - note that I did not test relevancy
as that'll break anytime we adjust something.

## Additional information

Note that the change in API schema means the API does not work with the
frontend, but there are a few hacky changes you can make to connect
them. In
[BaseApi.ts](https://github.com/navapbc/simpler-grants-gov/blob/main/frontend/src/app/api/BaseApi.ts#L47)
change the version to `v1`. In
[SearchOpportunityAPI.ts](https://github.com/navapbc/simpler-grants-gov/blob/main/frontend/src/app/api/SearchOpportunityAPI.ts#L56)
add `response.data = response.data.opportunities;` to the end of the
`searchOpportunities` method.

With that, the local frontend will work.

To actually get everything running locally, you can run:
```sh
make db-recreate
make init
make db-seed-local args="--iterations 10"
poetry run flask load-search-data load-opportunity-data
make run-logs
# and also to run the frontend
npm run dev
```
Then go to http://localhost:3000/search

---------

Co-authored-by: nava-platform-bot <[email protected]>
  • Loading branch information
chouinar and nava-platform-bot authored Jun 27, 2024
1 parent 817a1d3 commit cbccbed
Show file tree
Hide file tree
Showing 15 changed files with 1,375 additions and 120 deletions.
248 changes: 210 additions & 38 deletions api/openapi.generated.yml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion api/src/adapters/search/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from src.adapters.search.opensearch_client import SearchClient
from src.adapters.search.opensearch_config import get_opensearch_config
from src.adapters.search.opensearch_query_builder import SearchQueryBuilder

__all__ = ["SearchClient", "get_opensearch_config"]
__all__ = ["SearchClient", "get_opensearch_config", "SearchQueryBuilder"]
47 changes: 47 additions & 0 deletions api/src/adapters/search/flask_opensearch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from functools import wraps
from typing import Callable, Concatenate, ParamSpec, TypeVar

from flask import Flask, current_app

from src.adapters.search import SearchClient

_SEARCH_CLIENT_KEY = "search-client"


def register_search_client(search_client: SearchClient, app: Flask) -> None:
app.extensions[_SEARCH_CLIENT_KEY] = search_client


def get_search_client(app: Flask) -> SearchClient:
return app.extensions[_SEARCH_CLIENT_KEY]


P = ParamSpec("P")
T = TypeVar("T")


def with_search_client() -> Callable[[Callable[Concatenate[SearchClient, P], T]], Callable[P, T]]:
"""
Decorator for functions that need a search client.
This decorator will return the shared search client object which
has an internal connection pool that is shared.
Usage:
@with_search_client()
def foo(search_client: search.SearchClient):
...
@with_search_client()
def bar(search_client: search.SearchClient, x: int, y: int):
...
"""

def decorator(f: Callable[Concatenate[SearchClient, P], T]) -> Callable[P, T]:
@wraps(f)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
return f(get_search_client(current_app), *args, **kwargs)

return wrapper

return decorator
5 changes: 1 addition & 4 deletions api/src/adapters/search/opensearch_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@
"default": {
"type": "custom",
"filter": ["lowercase", "custom_stemmer"],
# Change tokenization to whitespace as the default is very clunky
# with a lot of our IDs that have dashes in them.
# see: https://opensearch.org/docs/latest/analyzers/tokenizers/index/
"tokenizer": "whitespace",
"tokenizer": "standard",
}
},
# Change the default stemming to use snowball which handles plural
Expand Down
75 changes: 68 additions & 7 deletions api/src/api/opportunities_v1/opportunity_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import src.adapters.db as db
import src.adapters.db.flask_db as flask_db
import src.adapters.search as search
import src.adapters.search.flask_opensearch as flask_opensearch
import src.api.opportunities_v1.opportunity_schemas as opportunity_schemas
import src.api.response as response
from src.api.opportunities_v1.opportunity_blueprint import opportunity_blueprint
Expand All @@ -24,20 +26,76 @@
See [Release Phases](https://github.com/github/roadmap?tab=readme-ov-file#release-phases) for further details.
"""

examples = {
"example1": {
"summary": "No filters",
"value": {
"pagination": {
"order_by": "opportunity_id",
"page_offset": 1,
"page_size": 25,
"sort_direction": "ascending",
},
},
},
"example2": {
"summary": "All filters",
"value": {
"query": "research",
"filters": {
"agency": {"one_of": ["USAID", "ARPAH"]},
"applicant_type": {
"one_of": ["state_governments", "county_governments", "individuals"]
},
"funding_category": {"one_of": ["recovery_act", "arts", "natural_resources"]},
"funding_instrument": {"one_of": ["cooperative_agreement", "grant"]},
"opportunity_status": {"one_of": ["forecasted", "posted"]},
},
"pagination": {
"order_by": "opportunity_id",
"page_offset": 1,
"page_size": 25,
"sort_direction": "descending",
},
},
},
"example3": {
"summary": "Query & opportunity_status filters",
"value": {
"query": "research",
"filters": {
"opportunity_status": {"one_of": ["forecasted", "posted"]},
},
"pagination": {
"order_by": "opportunity_id",
"page_offset": 1,
"page_size": 25,
"sort_direction": "descending",
},
},
},
}


@opportunity_blueprint.post("/opportunities/search")
@opportunity_blueprint.input(
opportunity_schemas.OpportunitySearchRequestV1Schema, arg_name="search_params"
opportunity_schemas.OpportunitySearchRequestV1Schema,
arg_name="search_params",
examples=examples,
)
# many=True allows us to return a list of opportunity objects
@opportunity_blueprint.output(opportunity_schemas.OpportunitySearchResponseV1Schema)
@opportunity_blueprint.output(opportunity_schemas.OpportunitySearchResponseV1Schema())
@opportunity_blueprint.auth_required(api_key_auth)
@opportunity_blueprint.doc(description=SHARED_ALPHA_DESCRIPTION)
def opportunity_search(search_params: dict) -> response.ApiResponse:
@flask_opensearch.with_search_client()
def opportunity_search(
search_client: search.SearchClient, search_params: dict
) -> response.ApiResponse:
add_extra_data_to_current_request_logs(flatten_dict(search_params, prefix="request.body"))
logger.info("POST /v1/opportunities/search")

opportunities, pagination_info = search_opportunities(search_params)
opportunities, aggregations, pagination_info = search_opportunities(
search_client, search_params
)

add_extra_data_to_current_request_logs(
{
Expand All @@ -48,12 +106,15 @@ def opportunity_search(search_params: dict) -> response.ApiResponse:
logger.info("Successfully fetched opportunities")

return response.ApiResponse(
message="Success", data=opportunities, pagination_info=pagination_info
message="Success",
data=opportunities,
facet_counts=aggregations,
pagination_info=pagination_info,
)


@opportunity_blueprint.get("/opportunities/<int:opportunity_id>")
@opportunity_blueprint.output(opportunity_schemas.OpportunityGetResponseV1Schema)
@opportunity_blueprint.output(opportunity_schemas.OpportunityGetResponseV1Schema())
@opportunity_blueprint.auth_required(api_key_auth)
@opportunity_blueprint.doc(description=SHARED_ALPHA_DESCRIPTION)
@flask_db.with_db_session()
Expand Down
Loading

0 comments on commit cbccbed

Please sign in to comment.