Skip to content

Commit

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

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.

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.

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
npm run dev
```
Then go to http://localhost:3000/search

---------

Co-authored-by: nava-platform-bot <platform-admins@navapbc.com>
  • Loading branch information
2 people authored and acouch committed Sep 18, 2024
1 parent 4162f92 commit c0966aa
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 c0966aa

Please sign in to comment.