Skip to content

Commit

Permalink
[Issue 644] Add a GET /opportunity/:opportunity-id endpoint (#660)
Browse files Browse the repository at this point in the history
[Issue 644] Add a GET /opportunity/:opportunity-id endpoint

---------

Co-authored-by: nava-platform-bot <platform-admins@navapbc.com>
  • Loading branch information
chouinar and nava-platform-bot authored Nov 9, 2023
1 parent eab68da commit c9503dd
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 12 deletions.
53 changes: 53 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions api/src/api/opportunities/opportunity_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -35,3 +36,14 @@ def opportunity_search(
return response.ApiResponse(
message="Success", data=opportunities, pagination_info=pagination_info
)


@opportunity_blueprint.get("/v1/opportunities/<int:opportunity_id>")
@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)
18 changes: 18 additions & 0 deletions api/src/api/route_utils.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions api/src/services/opportunities/get_opportunities.py
Original file line number Diff line number Diff line change
@@ -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
72 changes: 60 additions & 12 deletions api/tests/src/route/test_opportunity_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<opportunity_id>
#####################################


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"
)

0 comments on commit c9503dd

Please sign in to comment.