From c9503dd41a0b620b9d91b86605adc099e1b75364 Mon Sep 17 00:00:00 2001 From: Michael Chouinard <46358556+chouinar@users.noreply.github.com> Date: Thu, 9 Nov 2023 13:04:16 -0500 Subject: [PATCH] [Issue 644] Add a GET /opportunity/:opportunity-id endpoint (#660) [Issue 644] Add a GET /opportunity/:opportunity-id endpoint --------- Co-authored-by: nava-platform-bot --- api/openapi.generated.yml | 53 ++++++++++++++ .../api/opportunities/opportunity_routes.py | 12 ++++ api/src/api/route_utils.py | 18 +++++ .../opportunities/get_opportunities.py | 18 +++++ api/tests/src/route/test_opportunity_route.py | 72 +++++++++++++++---- 5 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 api/src/api/route_utils.py create mode 100644 api/src/services/opportunities/get_opportunities.py diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 2d21553b6..c31355687 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -111,6 +111,59 @@ paths: $ref: '#/components/schemas/OpportunitySearch' security: - ApiKeyAuth: [] + /v1/opportunities/{opportunity_id}: + get: + parameters: + - in: path + name: opportunity_id + schema: + type: integer + required: true + responses: + '200': + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: The message to return + data: + $ref: '#/components/schemas/Opportunity' + status_code: + type: integer + description: The HTTP status code + warnings: + type: array + items: + $ref: '#/components/schemas/ValidationError' + errors: + type: array + items: + $ref: '#/components/schemas/ValidationError' + pagination_info: + description: The pagination information for paginated endpoints + allOf: + - $ref: '#/components/schemas/PaginationInfo' + description: Successful response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + description: Authentication error + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + description: Not found + tags: + - Opportunity + summary: Opportunity Get + security: + - ApiKeyAuth: [] openapi: 3.1.0 components: schemas: diff --git a/api/src/api/opportunities/opportunity_routes.py b/api/src/api/opportunities/opportunity_routes.py index 825ed9641..ee880960a 100644 --- a/api/src/api/opportunities/opportunity_routes.py +++ b/api/src/api/opportunities/opportunity_routes.py @@ -7,6 +7,7 @@ from src.api.feature_flags.feature_flag_config import FeatureFlagConfig from src.api.opportunities.opportunity_blueprint import opportunity_blueprint from src.auth.api_key_auth import api_key_auth +from src.services.opportunities.get_opportunities import get_opportunity from src.services.opportunities.search_opportunities import search_opportunities logger = logging.getLogger(__name__) @@ -35,3 +36,14 @@ def opportunity_search( return response.ApiResponse( message="Success", data=opportunities, pagination_info=pagination_info ) + + +@opportunity_blueprint.get("/v1/opportunities/") +@opportunity_blueprint.output(opportunity_schemas.OpportunitySchema) +@opportunity_blueprint.auth_required(api_key_auth) +@flask_db.with_db_session() +def opportunity_get(db_session: db.Session, opportunity_id: int) -> response.ApiResponse: + with db_session.begin(): + opportunity = get_opportunity(db_session, opportunity_id) + + return response.ApiResponse(message="Success", data=opportunity) diff --git a/api/src/api/route_utils.py b/api/src/api/route_utils.py new file mode 100644 index 000000000..1dccebcd3 --- /dev/null +++ b/api/src/api/route_utils.py @@ -0,0 +1,18 @@ +from typing import Any, Never + +from apiflask import abort +from apiflask.types import ResponseHeaderType + + +def raise_flask_error( # type: ignore + status_code: int, + message: str | None = None, + detail: Any = None, + headers: ResponseHeaderType | None = None, + # TODO - when we work on validation error responses, we'll want to take in those here +) -> Never: + # Wrapper around the abort method which makes an error during API processing + # work properly when APIFlask generates a response. + # mypy doesn't realize this method never returns, so we define the same method + # with a return type of Never. + abort(status_code, message, detail, headers) diff --git a/api/src/services/opportunities/get_opportunities.py b/api/src/services/opportunities/get_opportunities.py new file mode 100644 index 000000000..988ada196 --- /dev/null +++ b/api/src/services/opportunities/get_opportunities.py @@ -0,0 +1,18 @@ +from sqlalchemy import select + +import src.adapters.db as db +from src.api.route_utils import raise_flask_error +from src.db.models.opportunity_models import Opportunity + + +def get_opportunity(db_session: db.Session, opportunity_id: int) -> Opportunity: + # TODO - issue-642 - figure out if we want to filter by is_draft here as well + + opportunity: Opportunity | None = db_session.execute( + select(Opportunity).where(Opportunity.opportunity_id == opportunity_id) + ).scalar_one_or_none() + + if opportunity is None: + raise_flask_error(404, message=f"Could not find Opportunity with ID {opportunity_id}") + + return opportunity diff --git a/api/tests/src/route/test_opportunity_route.py b/api/tests/src/route/test_opportunity_route.py index edeb3a69a..11c2c2a16 100644 --- a/api/tests/src/route/test_opportunity_route.py +++ b/api/tests/src/route/test_opportunity_route.py @@ -87,6 +87,11 @@ def setup_opportunities(enable_factory_create, truncate_opportunities): OpportunityFactory.create(category=OpportunityCategory.MANDATORY, is_draft=False) +##################################### +# POST /opportunities/search +##################################### + + @pytest.mark.parametrize( "search_request,expected_values", [ @@ -302,18 +307,6 @@ def test_opportunity_search_invalid_request_422( assert response_data == expected_response_data -def test_opportunity_search_unauthorized_401(client, api_auth_token): - response = client.post( - "/v1/opportunities/search", json=get_search_request(), headers={"X-Auth": "incorrect token"} - ) - - assert response.status_code == 401 - assert ( - response.get_json()["message"] - == "The server could not verify that you are authorized to access the URL requested" - ) - - @pytest.mark.parametrize("enable_opportunity_log_msg", [True, False, None]) def test_opportunity_search_feature_flag_200( client, api_auth_token, enable_opportunity_log_msg, caplog @@ -346,3 +339,58 @@ def test_opportunity_search_feature_flag_invalid_value_422( response_data = resp.get_json()["detail"]["headers"] assert response_data == {"X-FF-Enable-Opportunity-Log-Msg": ["Not a valid boolean."]} + + +##################################### +# GET /opportunities/ +##################################### + + +def test_get_opportunity_200(client, api_auth_token, enable_factory_create): + opportunity = OpportunityFactory.create() + + resp = client.get( + f"/v1/opportunities/{opportunity.opportunity_id}", headers={"X-Auth": api_auth_token} + ) + assert resp.status_code == 200 + + response_data = resp.get_json()["data"] + + assert response_data["opportunity_id"] == opportunity.opportunity_id + assert response_data["opportunity_title"] == opportunity.opportunity_title + assert response_data["agency"] == opportunity.agency + assert response_data["category"] == opportunity.category + + +def test_get_opportunity_not_found_404(client, api_auth_token, truncate_opportunities): + resp = client.get("/v1/opportunities/1", headers={"X-Auth": api_auth_token}) + assert resp.status_code == 404 + assert resp.get_json()["message"] == "Could not find Opportunity with ID 1" + + +def test_get_opportunity_invalid_id_404(client, api_auth_token): + # with how the route naming resolves, this won't be an invalid request, but instead a 404 + resp = client.get("/v1/opportunities/text", headers={"X-Auth": api_auth_token}) + assert resp.status_code == 404 + assert resp.get_json()["message"] == "Not Found" + + +##################################### +# Auth tests +##################################### +@pytest.mark.parametrize( + "method,url,body", + [ + ("POST", "/v1/opportunities/search", get_search_request()), + ("GET", "/v1/opportunities/1", None), + ], +) +def test_opportunity_unauthorized_401(client, api_auth_token, method, url, body): + # open is just the generic method that post/get/etc. call under the hood + response = client.open(url, method=method, json=body, headers={"X-Auth": "incorrect token"}) + + assert response.status_code == 401 + assert ( + response.get_json()["message"] + == "The server could not verify that you are authorized to access the URL requested" + )