From c1c28a843165767dd6f77d88017ffad7d269a1e5 Mon Sep 17 00:00:00 2001 From: Federico Pacheco Date: Thu, 18 Apr 2024 01:44:01 -0300 Subject: [PATCH] social_user is created in login/create_user && get endpoint changed: can now filter by lists of ids by query param. --- .env.dist | 9 ++- app/controller/Users.py | 75 ++++++++++++------- .../InternalServerErrorException.py | 17 +++++ app/exceptions/UserException.py | 3 +- app/main.py | 19 +++-- app/models/users.py | 4 +- app/repository/Users.py | 14 ++-- app/service/Social.py | 40 ++++++++++ app/service/Users.py | 34 +++++---- requirements.txt | 1 + 10 files changed, 152 insertions(+), 64 deletions(-) create mode 100644 app/exceptions/InternalServerErrorException.py create mode 100644 app/service/Social.py diff --git a/.env.dist b/.env.dist index 30e7630..2a8f76b 100644 --- a/.env.dist +++ b/.env.dist @@ -1,7 +1,6 @@ # Users service LOGGING_LEVEL= PORT= -JWT_SECRET= # Postgres POSTGRES_USER= @@ -14,4 +13,10 @@ POSTGRES_SCHEMA= # Google GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -GOOGLE_REDIRECT_URI= \ No newline at end of file +GOOGLE_REDIRECT_URI= + +# JWT System +JWT_SECRET= + +# External services +SOCIAL_SERVICE_URL= \ No newline at end of file diff --git a/app/controller/Users.py b/app/controller/Users.py index 9ce09f1..ab956d7 100644 --- a/app/controller/Users.py +++ b/app/controller/Users.py @@ -1,6 +1,7 @@ from fastapi import status from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse +from service.Social import SocialService from service.Users import UsersService @@ -12,43 +13,63 @@ def handle_get_user(self, user_id: int): user = self.users_service.get_user(user_id) return JSONResponse( status_code=status.HTTP_200_OK, - content=jsonable_encoder({ - "message": user, - "status": status.HTTP_200_OK, - }) + content=jsonable_encoder( + { + "message": user, + "status": status.HTTP_200_OK, + } + ), ) - def handle_get_all_users(self): - users = self.users_service.get_all_users() - return JSONResponse( - status_code=status.HTTP_200_OK, - content=jsonable_encoder({ - "message": users, - "status": status.HTTP_200_OK, - }) - ) + def handle_get_all_users(self, ids: list = None): + if ids: + users = self.users_service.get_users_by_ids(ids) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=jsonable_encoder( + { + "message": users, + "status": status.HTTP_200_OK, + } + ), + ) + else: + users = self.users_service.get_all_users() + return JSONResponse( + status_code=status.HTTP_200_OK, + content=jsonable_encoder( + { + "message": users, + "status": status.HTTP_200_OK, + } + ), + ) - def handle_create_user(self, user_data: dict): - self.users_service.create_user(user_data) + async def handle_create_user(self, user_data: dict): + result = self.users_service.create_user(user_data) + await SocialService.create_social_user(user_id=result.id) return JSONResponse( status_code=status.HTTP_201_CREATED, - content=jsonable_encoder({ - "message": "User created successfully", - "status": status.HTTP_201_CREATED, - }) + content=jsonable_encoder( + { + "message": "User created successfully", + "status": status.HTTP_201_CREATED, + } + ), ) - def handle_login(self, auth_code: str): + async def handle_login(self, auth_code: str): user, jwt_token = self.users_service.login(auth_code) + await SocialService.create_social_user(user_id=user.get("id")) return JSONResponse( status_code=status.HTTP_200_OK, - content=jsonable_encoder({ - "message": user, - "status": status.HTTP_200_OK, - }), - headers={ - "x-access-token": f"{jwt_token}" - }, + content=jsonable_encoder( + { + "message": user, + "status": status.HTTP_200_OK, + } + ), + headers={"x-access-token": f"{jwt_token}"}, ) def handle_update_user(self, user_id: int, update_data: dict): diff --git a/app/exceptions/InternalServerErrorException.py b/app/exceptions/InternalServerErrorException.py new file mode 100644 index 0000000..e1dae6c --- /dev/null +++ b/app/exceptions/InternalServerErrorException.py @@ -0,0 +1,17 @@ +from fastapi import HTTPException, status +from typing import Optional + + +class InternalServerErrorException(HTTPException): + def __init__( + self, + microservice: Optional[str] = "User service", + detail: Optional[str] = None, + ): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + message = f"An error occurred in the {microservice}" + + if detail: + message += f": {detail.lower()}" + + super().__init__(status_code=status_code, detail=message) diff --git a/app/exceptions/UserException.py b/app/exceptions/UserException.py index 022828e..ac630fe 100644 --- a/app/exceptions/UserException.py +++ b/app/exceptions/UserException.py @@ -12,8 +12,7 @@ class InvalidData(HTTPException): def __init__(self): status_code = status.HTTP_400_BAD_REQUEST super().__init__( - status_code=status_code, - detail="Invalid user data was provided" + status_code=status_code, detail="Invalid user data was provided" ) diff --git a/app/main.py b/app/main.py index 3bfe499..4527a58 100644 --- a/app/main.py +++ b/app/main.py @@ -4,7 +4,8 @@ from repository.Users import UsersRepository from schemas.Schemas import CreateUserSchema, UpdateUserSchema from schemas.Schemas import LoginRequest - +from typing import List, Annotated, Union +from fastapi import FastAPI, Query app = FastAPI() users_repository = UsersRepository() @@ -23,18 +24,22 @@ def get_users(user_id: int): @app.get("/users") -def get_all_users(): - return users_controller.handle_get_all_users() +def get_all_users(ids: Annotated[Union[List[str], None], Query()] = None): + if ids: + ids = ids[0].split(",") + return users_controller.handle_get_all_users(ids) + else: + return users_controller.handle_get_all_users() @app.post("/users") -def create_user(user_data: CreateUserSchema): - return users_controller.handle_create_user(user_data.dict()) +async def create_user(user_data: CreateUserSchema): + return await users_controller.handle_create_user(user_data.dict()) @app.post("/login") -def login_with_google(request: LoginRequest): - return users_controller.handle_login(request.auth_code) +async def login_with_google(request: LoginRequest): + return await users_controller.handle_login(request.auth_code) @app.patch("/users/{user_id}") diff --git a/app/models/users.py b/app/models/users.py index ab73efe..f350ee5 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -5,9 +5,7 @@ class User(Base): __tablename__ = "users" - __table_args__ = {"schema": environ.get( - "POSTGRES_SCHEMA", - "users_service")} + __table_args__ = {"schema": environ.get("POSTGRES_SCHEMA", "users_service")} id = Column(Integer, primary_key=True, index=True, autoincrement=True) name = Column(String, nullable=True) email = Column(String, nullable=False) diff --git a/app/repository/Users.py b/app/repository/Users.py index 54cfdaa..d4c95c5 100644 --- a/app/repository/Users.py +++ b/app/repository/Users.py @@ -8,11 +8,7 @@ class UsersRepository: - db_url = environ.get( - "DATABASE_URL").replace( - "postgres://", - "postgresql://", - 1) + db_url = environ.get("DATABASE_URL").replace("postgres://", "postgresql://", 1) engine = create_engine(db_url) @@ -39,10 +35,14 @@ def get_user_by_email(self, email: str): user = self.session.query(User).filter_by(email=email).first() return user.__dict__ if user else None - def get_all_users(self): + def get_all_users(self, ids: list = None): users = self.session.query(User).all() return self.__parse_result(users) + def get_users_by_ids(self, ids: list): + users = self.session.query(User).filter(User.id.in_(ids)).all() + return self.__parse_result(users) + def create_user( self, email: str, @@ -52,7 +52,7 @@ def create_user( nickname: Optional[str] = None, biography: Optional[str] = None, location: Optional[dict] = None, - birthdate: Optional[date] = None + birthdate: Optional[date] = None, ) -> User: user_data = {"email": email} diff --git a/app/service/Social.py b/app/service/Social.py new file mode 100644 index 0000000..6b1db28 --- /dev/null +++ b/app/service/Social.py @@ -0,0 +1,40 @@ +import logging +from httpx import AsyncClient, Response, AsyncHTTPTransport +from os import environ + +from exceptions.InternalServerErrorException import InternalServerErrorException +from exceptions.UserException import InvalidData + +logger = logging.getLogger("social") +logger.setLevel("DEBUG") + +SOCIAL_SERVICE_URL = environ["SOCIAL_SERVICE_URL"] + +# Heroku dyno plan has a limit of 30 seconds... +# so, assign 3 retries of 10 seconds each :) +# https://devcenter.heroku.com/articles/request-timeout +NUMBER_OF_RETRIES = 3 +TIMEOUT = 10 + + +class SocialService: + @staticmethod + async def post(path: str, body: dict) -> Response: + async with AsyncClient( + transport=AsyncHTTPTransport(retries=NUMBER_OF_RETRIES), timeout=TIMEOUT + ) as client: + url = SOCIAL_SERVICE_URL + path + response = await client.post(url, json=body) + return response + + @staticmethod + async def create_social_user(user_id: int): + try: + response = await SocialService.post(f"/social/users", body={"id": user_id}) + if response.status_code == 201: + return + else: + raise InvalidData() + except Exception as e: + print(f"Unexpected error: {e}") + raise InternalServerErrorException("Social service") diff --git a/app/service/Users.py b/app/service/Users.py index a78442b..16a9fbd 100644 --- a/app/service/Users.py +++ b/app/service/Users.py @@ -21,6 +21,9 @@ def get_user(self, user_id: int): def get_all_users(self): return self.user_repository.get_all_users() + def get_users_by_ids(self, ids: list): + return self.user_repository.get_users_by_ids(ids) + def create_user(self, user_data: dict): if not self._validate_location(user_data.get("location")): raise InvalidData() @@ -31,12 +34,14 @@ def update_user(self, user_id: int, update_data: dict): # es el propio usuario editando sus datos y no permitir # que un usuario edite los de un tercero self.get_user(user_id) - filtered_update_data = {k: v for k, v in update_data.items() - if v is not None} - if 'photo' in filtered_update_data: - photo_url = filtered_update_data['photo'] - if not re.match(r'^https?://(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}' - r'(?:/[^/#?]+)+(?:\?.*)?$', photo_url): + filtered_update_data = {k: v for k, v in update_data.items() if v is not None} + if "photo" in filtered_update_data: + photo_url = filtered_update_data["photo"] + if not re.match( + r"^https?://(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}" + r"(?:/[^/#?]+)+(?:\?.*)?$", + photo_url, + ): raise InvalidURL("Invalid photo URL") self.user_repository.edit_user(user_id, filtered_update_data) @@ -53,9 +58,7 @@ def login(self, auth_code: str): user = self.user_repository.get_user_by_email(user_info["email"]) user_id = user.get("id") payload = {"user_id": user_id} - jwt_token = jwt.encode(payload, - os.environ["JWT_SECRET"], - algorithm="HS256") + jwt_token = jwt.encode(payload, os.environ["JWT_SECRET"], algorithm="HS256") return user, jwt_token def _get_access_token(self, authorization_code): @@ -65,7 +68,7 @@ def _get_access_token(self, authorization_code): "client_secret": os.environ["GOOGLE_CLIENT_SECRET"], "code": authorization_code, "grant_type": "authorization_code", - "redirect_uri": os.environ["GOOGLE_REDIRECT_URI"] + "redirect_uri": os.environ["GOOGLE_REDIRECT_URI"], } response = requests.post(token_url, data=payload) if response.status_code == 200: @@ -82,18 +85,17 @@ def _get_user_info(self, access_token): if response.status_code != 200: raise AuthenticationError() - user_data = {'email': response.json().get("email")} + user_data = {"email": response.json().get("email")} if response.json().get("gender") is not None: - user_data['gender'] = response.json().get("gender") + user_data["gender"] = response.json().get("gender") if response.json().get("name") is not None: - user_data['name'] = response.json().get("name") + user_data["name"] = response.json().get("name") if response.json().get("picture") is not None: - user_data['photo'] = response.json().get("picture") + user_data["photo"] = response.json().get("picture") return user_data def _validate_location(self, location): if "lat" in location and "long" in location: - if -90 <= location["lat"] <= 90 and \ - -180 <= location["long"] <= 180: + if -90 <= location["lat"] <= 90 and -180 <= location["long"] <= 180: return True return False diff --git a/requirements.txt b/requirements.txt index a412c0f..fb3d98f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ psycopg2-binary SQLAlchemy requests_oauthlib pyjwt +httpx \ No newline at end of file