Skip to content

Commit

Permalink
[Issue 2676] Create a GET /users/:userID endpoint (#3041)
Browse files Browse the repository at this point in the history
## Summary
Fixes #{[2676](#2676)}

### Time to review: __15 mins__

## Changes proposed
Created GET `v1/users/:user_id` endpoint ( expected user_id type UUID)
Created `UserGetResponseSchema` 
Created `get_user` service method to fetch user object 
Created 2 tests: 
1. Test correct user object returned 
2. Test 401 error when auth user request for another user_id 

## Context for reviewers
This endpoint should have authentication configured 
The endpoint should validate that the user ID matches the user ID being
queried and raise a 401 Unauthorized if not

---------

Co-authored-by: nava-platform-bot <[email protected]>
  • Loading branch information
babebe and nava-platform-bot authored Nov 26, 2024
1 parent 0df81b5 commit e504342
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 15 deletions.
48 changes: 48 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,38 @@ paths:
sort_direction: descending
security:
- ApiKeyAuth: []
/v1/users/{user_id}:
get:
parameters:
- in: path
name: user_id
schema:
type: string
required: true
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserGetResponse'
description: Successful response
'401':
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: Authentication error
'404':
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: Not found
tags:
- User v1
summary: User Get
security:
- ApiJwtAuth: []
/v1/opportunities/{opportunity_id}:
get:
parameters:
Expand Down Expand Up @@ -1490,6 +1522,22 @@ components:
- object
allOf:
- $ref: '#/components/schemas/OpportunityFacetV1'
UserGetResponse:
type: object
properties:
message:
type: string
description: The message to return
example: Success
data:
type:
- object
allOf:
- $ref: '#/components/schemas/User'
status_code:
type: integer
description: The HTTP status code
example: 200
OpportunityAttachmentV1:
type: object
properties:
Expand Down
53 changes: 39 additions & 14 deletions api/src/api/users/user_routes.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import logging
from uuid import UUID

from src.adapters import db
from src.adapters.db import flask_db
from src.api import response
from src.api.route_utils import raise_flask_error
from src.api.users import user_schemas
from src.api.users.user_blueprint import user_blueprint
from src.api.users.user_schemas import UserTokenLogoutResponseSchema, UserTokenRefreshResponseSchema
from src.api.users.user_schemas import (
UserGetResponseSchema,
UserTokenLogoutResponseSchema,
UserTokenRefreshResponseSchema,
)
from src.auth.api_jwt_auth import api_jwt_auth, refresh_token_expiration
from src.auth.api_key_auth import api_key_auth
from src.db.models.user_models import UserTokenSession
from src.services.users.get_user import get_user

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -41,22 +47,21 @@ def user_token(x_oauth_login_gov: dict) -> response.ApiResponse:
raise_flask_error(400, message)


@user_blueprint.post("/token/refresh")
@user_blueprint.output(UserTokenRefreshResponseSchema)
@user_blueprint.post("/token/logout")
@user_blueprint.output(UserTokenLogoutResponseSchema)
@user_blueprint.doc(responses=[200, 401])
@user_blueprint.auth_required(api_jwt_auth)
@flask_db.with_db_session()
def user_token_refresh(db_session: db.Session) -> response.ApiResponse:
logger.info("POST /v1/users/token/refresh")
def user_token_logout(db_session: db.Session) -> response.ApiResponse:
logger.info("POST /v1/users/token/logout")

user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore

with db_session.begin():
refresh_token_expiration(user_token_session)
user_token_session.is_valid = False
db_session.add(user_token_session)

logger.info(
"Refreshed a user token",
"Logged out a user",
extra={
"user_token_session.token_id": str(user_token_session.token_id),
"user_token_session.user_id": str(user_token_session.user_id),
Expand All @@ -66,25 +71,45 @@ def user_token_refresh(db_session: db.Session) -> response.ApiResponse:
return response.ApiResponse(message="Success")


@user_blueprint.post("/token/logout")
@user_blueprint.output(UserTokenLogoutResponseSchema)
@user_blueprint.post("/token/refresh")
@user_blueprint.output(UserTokenRefreshResponseSchema)
@user_blueprint.doc(responses=[200, 401])
@user_blueprint.auth_required(api_jwt_auth)
@flask_db.with_db_session()
def user_token_logout(db_session: db.Session) -> response.ApiResponse:
logger.info("POST /v1/users/token/logout")
def user_token_refresh(db_session: db.Session) -> response.ApiResponse:
logger.info("POST /v1/users/token/refresh")

user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore

with db_session.begin():
user_token_session.is_valid = False
refresh_token_expiration(user_token_session)
db_session.add(user_token_session)

logger.info(
"Logged out a user",
"Refreshed a user token",
extra={
"user_token_session.token_id": str(user_token_session.token_id),
"user_token_session.user_id": str(user_token_session.user_id),
},
)

return response.ApiResponse(message="Success")


@user_blueprint.get("/<uuid:user_id>")
@user_blueprint.output(UserGetResponseSchema)
@user_blueprint.doc(responses=[200, 401])
@user_blueprint.auth_required(api_jwt_auth)
@flask_db.with_db_session()
def user_get(db_session: db.Session, user_id: UUID) -> response.ApiResponse:
logger.info("GET /v1/users/:user_id")

user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore

if user_token_session.user_id == user_id:
with db_session.begin():
user = get_user(db_session, user_id)

return response.ApiResponse(message="Success", data=user)

raise_flask_error(401, "Unauthorized user")
4 changes: 4 additions & 0 deletions api/src/api/users/user_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ class UserTokenRefreshResponseSchema(AbstractResponseSchema):
class UserTokenLogoutResponseSchema(AbstractResponseSchema):
# No data returned
data = fields.MixinField(metadata={"example": None})


class UserGetResponseSchema(AbstractResponseSchema):
data = fields.Nested(UserSchema)
Empty file.
18 changes: 18 additions & 0 deletions api/src/services/users/get_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from uuid import UUID

from sqlalchemy import select

from src.adapters import db
from src.db.models.user_models import LinkExternalUser


def _fetch_user(db_session: db.Session, user_id: UUID) -> LinkExternalUser | None:
stmt = select(LinkExternalUser).where(LinkExternalUser.user_id == user_id)

user = db_session.execute(stmt).scalar_one_or_none()

return user


def get_user(db_session: db.Session, user_id: UUID) -> LinkExternalUser | None:
return _fetch_user(db_session, user_id)
30 changes: 30 additions & 0 deletions api/tests/src/api/users/test_user_route_get.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import uuid

from src.auth.api_jwt_auth import create_jwt_for_user
from tests.src.db.models.factories import LinkExternalUserFactory

################
# GET user tests
################


def test_get_user_200(enable_factory_create, client, db_session, api_auth_token):
external_user = LinkExternalUserFactory.create()
token, _ = create_jwt_for_user(external_user.user, db_session)
db_session.commit()

resp = client.get(f"/v1/users/{external_user.user_id}", headers={"X-SGG-Token": token})

assert resp.status_code == 200
assert resp.get_json()["data"]["user_id"] == str(external_user.user_id)


def test_get_user_401(enable_factory_create, client, db_session, api_auth_token):
external_user = LinkExternalUserFactory.create()
token, _ = create_jwt_for_user(external_user.user, db_session)
db_session.commit()

random_uuid = str(uuid.uuid4())
resp = client.get(f"/v1/users/{random_uuid}", headers={"X-SGG-Token": token})

assert resp.status_code == 401
2 changes: 1 addition & 1 deletion api/tests/src/db/models/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -1858,7 +1858,7 @@ class Meta:
user = factory.SubFactory(UserFactory)
user_id = factory.LazyAttribute(lambda s: s.user.user_id)

external_user_type_id = factory.fuzzy.FuzzyChoice(ExternalUserType)
external_user_type = factory.fuzzy.FuzzyChoice(ExternalUserType)

email = factory.Faker("email")

Expand Down

0 comments on commit e504342

Please sign in to comment.