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

[Issue #16] Connect the API to use the search index #63

Merged
merged 20 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Member

@acouch acouch Jun 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to match the stemmer chosen in the utils?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the utils, I found some issues with what I had configured in the prior PR when setting it up with our actual data and fixed it here.

}
},
# 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
Loading