From b333c1136031cd36b220c90d9f3ee6ea1366e342 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Wed, 25 Sep 2024 18:00:03 -0300 Subject: [PATCH 1/9] Handle correct status codes (200 or 201) when updating or creating a user --- src/api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/api.py b/src/api.py index 91d480e..b8c7855 100644 --- a/src/api.py +++ b/src/api.py @@ -153,7 +153,7 @@ async def create_or_update_user( user: schemas.UsersSchema, email: str, db: DbContextManager = Depends(DbContextManager), -) -> dict: +) -> JSONResponse: """Cria um usuário da API ou atualiza os seus dados cadastrais.""" # Validações @@ -177,6 +177,7 @@ async def create_or_update_user( ) from exception # Call + response_status = status.HTTP_200_OK try: # update if await crud_auth.get_user(db, user.email): @@ -184,13 +185,16 @@ async def create_or_update_user( # create else: await crud_auth.create_user(db, user) + response_status = status.HTTP_201_CREATED except IntegrityError as exception: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"IntegrityError: {str(exception)}", ) from exception - return user.dict(exclude=["password"]) + return JSONResponse( + content=user.dict(exclude=["password"]), status_code=response_status + ) @app.get( From d20089156b16002ea7c92ea36f21d624a3e24295 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Thu, 26 Sep 2024 11:50:59 -0300 Subject: [PATCH 2/9] Fix status code expectation in create user as admin test --- tests/user_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/user_test.py b/tests/user_test.py index 4f6bd29..fe896ca 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -148,7 +148,7 @@ def test_create_user_logged_in_admin( response = client.put( f"/user/{USERS_TEST[0]['email']}", headers=header_usr_1, json=USERS_TEST[0] ) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_201_CREATED def test_create_user_without_required_fields( From 919a9cf189e87c1a61e75a6c4ef8cc516423b03b Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Thu, 26 Sep 2024 15:57:21 -0300 Subject: [PATCH 3/9] Use 403 status code when user is logged in, but does not have admin permissions --- src/crud_auth.py | 3 ++- tests/user_test.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/crud_auth.py b/src/crud_auth.py index 3103e91..eb3dc7a 100644 --- a/src/crud_auth.py +++ b/src/crud_auth.py @@ -182,7 +182,8 @@ async def get_current_admin_user( if not current_user.is_admin: raise HTTPException( - status_code=401, detail="Usuário não tem permissões de administrador" + status_code=status.HTTP_403_FORBIDDEN, + detail="Usuário não tem permissões de administrador", ) return current_user diff --git a/tests/user_test.py b/tests/user_test.py index fe896ca..d99e64e 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -71,7 +71,7 @@ def test_get_all_users_not_logged_in(client: Client, header_not_logged_in: dict) def test_get_all_users_logged_in_not_admin(client: Client, header_usr_2: dict): response = client.get("/users", headers=header_usr_2) - assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.status_code == status.HTTP_403_FORBIDDEN def test_get_all_users_logged_in_admin(client: Client, header_usr_1: dict): @@ -89,7 +89,7 @@ def test_get_user_logged_in_not_admin( client: Client, user2_credentials: dict, header_usr_2: dict # user is_admin=False ): response = client.get(f"/user/{user2_credentials['email']}", headers=header_usr_2) - assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.status_code == status.HTTP_403_FORBIDDEN def test_get_user_as_admin( @@ -139,7 +139,7 @@ def test_create_user_logged_in_not_admin( response = client.put( f"/user/{USERS_TEST[0]['email']}", headers=header_usr_2, json=USERS_TEST[0] ) - assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.status_code == status.HTTP_403_FORBIDDEN def test_create_user_logged_in_admin( From ca8372359fc42b67c756b06aab9f42ac50691bf3 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Thu, 26 Sep 2024 17:54:33 -0300 Subject: [PATCH 4/9] Apply black formatter --- src/schemas.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/schemas.py b/src/schemas.py index 1594f2b..456437a 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -206,10 +206,7 @@ def validate_data_avaliacao_data_inicio_periodo_avaliativo( ) -> "AvaliacaoRegistrosExecucaoSchema": """Valida se a data de avaliação dos registros de execução é posterior à data de início do período avaliativo.""" - if ( - self.data_avaliacao_registros_execucao - < self.data_inicio_periodo_avaliativo - ): + if self.data_avaliacao_registros_execucao < self.data_inicio_periodo_avaliativo: raise ValueError( "A data de avaliação de registros de execução deve ser " "igual ou posterior à data de início do período avaliativo." From afef3141b9e695f7c71557bf02617adccee5f341 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Thu, 26 Sep 2024 17:56:06 -0300 Subject: [PATCH 5/9] Sort imports alfabetically --- src/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api.py b/src/api.py index b8c7855..b6e7387 100644 --- a/src/api.py +++ b/src/api.py @@ -14,8 +14,9 @@ import crud import crud_auth -import email_config from db_config import DbContextManager, create_db_and_tables +import email_config +import response_schemas import schemas from util import check_permissions From b7d4522350ff6e37bbc1d5a91b84267028ae571b Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Wed, 2 Oct 2024 10:28:26 -0300 Subject: [PATCH 6/9] Create error response schemas for the OpenAPI documentation --- src/response_schemas.py | 57 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/response_schemas.py diff --git a/src/response_schemas.py b/src/response_schemas.py new file mode 100644 index 0000000..380b506 --- /dev/null +++ b/src/response_schemas.py @@ -0,0 +1,57 @@ +"""Esquemas Pydantic para as respostas da API. +""" + +from abc import ABC + +from fastapi import status +from pydantic import BaseModel + + +class ResponseData(ABC, BaseModel): + """Classe abstrata para conter os métodos comuns a todos os modelos + de dados de resposta da API.""" + + _title = "" + + @classmethod + def get_title(cls): + return cls._title.default + + +class ValidationError(BaseModel): + """Estrutura retornada pelo Pydantic para cada erro de validação.""" + + type: str + loc: list[str] + msg: str + ctx: dict + + +class ValidationErrorResponse(ResponseData): + """Resposta da API para erros de validação.""" + + _status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + _title = "Unprocessable Entity" + detail: list[ValidationError] + + +class UnauthorizedErrorResponse(ResponseData): + """Resposta da API para erros de autorização.""" + + _status_code = status.HTTP_401_UNAUTHORIZED + _title = "Unauthorized access" + detail: str + + +class ForbiddenErrorResponse(ResponseData): + """Resposta da API para erros de permissão.""" + + _status_code = status.HTTP_403_FORBIDDEN + _title = "Forbidden" + detail: str + +RESPONSE_MODEL_FOR_STATUS_CODE = { + status.HTTP_422_UNPROCESSABLE_ENTITY: ValidationErrorResponse, + status.HTTP_401_UNAUTHORIZED: UnauthorizedErrorResponse, + status.HTTP_403_FORBIDDEN: ForbiddenErrorResponse, +} From e76636428d0a67edf7e92168d01fe0b62934a21d Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Wed, 2 Oct 2024 10:29:05 -0300 Subject: [PATCH 7/9] Add error responses to documentation of token endpoint --- src/api.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/api.py b/src/api.py index b6e7387..c08daf1 100644 --- a/src/api.py +++ b/src/api.py @@ -92,8 +92,54 @@ async def docs_redirect( @app.post( "/token", summary="Autentica na API.", - response_model=schemas.Token, tags=["Auth"], + response_model=schemas.Token, + responses={ + status.HTTP_200_OK: { + "model": schemas.Token, + "description": "Successful authentication", + }, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + "model": response_schemas.ValidationErrorResponse, + "description": response_schemas.ValidationErrorResponse.get_title(), + "content": { + "application/json": { + "examples": { + "Invalid email format": { + "value": response_schemas.ValidationErrorResponse( + detail=[ + response_schemas.ValidationError( + type="value_error", + loc=["email"], + msg="value is not a valid email address: " + "An email address must have an @-sign.", + input="my_username", + ctx={ + "reason": "An email address must have an @-sign." + }, + url="https://errors.pydantic.dev/2.8/v/value_error", + ) + ] + ).json() + } + } + } + }, + }, + status.HTTP_401_UNAUTHORIZED: { + "model": response_schemas.UnauthorizedErrorResponse, + "description": response_schemas.UnauthorizedErrorResponse.get_title(), + "content": { + "application/json": { + "examples": { + "Invalid credentials": { + "value": {"detail": "Username ou password incorretos"} + } + } + } + }, + }, + }, ) async def login_for_access_token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], @@ -130,6 +176,25 @@ async def login_for_access_token( "/users", summary="Lista usuários da API.", tags=["Auth"], + response_model=list[schemas.UsersGetSchema], + responses={ + status.HTTP_200_OK: { + "model": list[schemas.UsersGetSchema], + "description": "Successful list of users", + }, + status.HTTP_401_UNAUTHORIZED: { + "description": "Unauthorized access", + "content": { + "application/json": { + "examples": { + "Invalid credentials": { + "value": {"detail": "Not authenticated"} + } + } + } + }, + }, + }, ) async def get_users( user_logged: Annotated[ # pylint: disable=unused-argument From 897d153651b46865945ae6394a03325f9a98ce23 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Thu, 3 Oct 2024 18:36:26 -0300 Subject: [PATCH 8/9] Remove explicit successful responses code 200 from documentation as those are added implicitly by default --- src/api.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/api.py b/src/api.py index c08daf1..ef8be85 100644 --- a/src/api.py +++ b/src/api.py @@ -95,10 +95,6 @@ async def docs_redirect( tags=["Auth"], response_model=schemas.Token, responses={ - status.HTTP_200_OK: { - "model": schemas.Token, - "description": "Successful authentication", - }, status.HTTP_422_UNPROCESSABLE_ENTITY: { "model": response_schemas.ValidationErrorResponse, "description": response_schemas.ValidationErrorResponse.get_title(), @@ -178,10 +174,6 @@ async def login_for_access_token( tags=["Auth"], response_model=list[schemas.UsersGetSchema], responses={ - status.HTTP_200_OK: { - "model": list[schemas.UsersGetSchema], - "description": "Successful list of users", - }, status.HTTP_401_UNAUTHORIZED: { "description": "Unauthorized access", "content": { From baa1f216db0ce28d48d0ef8a4b3263758ea2ddc3 Mon Sep 17 00:00:00 2001 From: Augusto Herrmann Date: Thu, 3 Oct 2024 23:39:25 -0300 Subject: [PATCH 9/9] Add more technical response schemas and examples to docs --- src/api.py | 143 +++++++++++++++++++++++----------------- src/response_schemas.py | 124 +++++++++++++++++++++++++++++----- 2 files changed, 190 insertions(+), 77 deletions(-) diff --git a/src/api.py b/src/api.py index ef8be85..d699676 100644 --- a/src/api.py +++ b/src/api.py @@ -95,46 +95,8 @@ async def docs_redirect( tags=["Auth"], response_model=schemas.Token, responses={ - status.HTTP_422_UNPROCESSABLE_ENTITY: { - "model": response_schemas.ValidationErrorResponse, - "description": response_schemas.ValidationErrorResponse.get_title(), - "content": { - "application/json": { - "examples": { - "Invalid email format": { - "value": response_schemas.ValidationErrorResponse( - detail=[ - response_schemas.ValidationError( - type="value_error", - loc=["email"], - msg="value is not a valid email address: " - "An email address must have an @-sign.", - input="my_username", - ctx={ - "reason": "An email address must have an @-sign." - }, - url="https://errors.pydantic.dev/2.8/v/value_error", - ) - ] - ).json() - } - } - } - }, - }, - status.HTTP_401_UNAUTHORIZED: { - "model": response_schemas.UnauthorizedErrorResponse, - "description": response_schemas.UnauthorizedErrorResponse.get_title(), - "content": { - "application/json": { - "examples": { - "Invalid credentials": { - "value": {"detail": "Username ou password incorretos"} - } - } - } - }, - }, + **response_schemas.email_validation_error, + 401: response_schemas.UnauthorizedErrorResponse.docs(), }, ) async def login_for_access_token( @@ -173,20 +135,7 @@ async def login_for_access_token( summary="Lista usuários da API.", tags=["Auth"], response_model=list[schemas.UsersGetSchema], - responses={ - status.HTTP_401_UNAUTHORIZED: { - "description": "Unauthorized access", - "content": { - "application/json": { - "examples": { - "Invalid credentials": { - "value": {"detail": "Not authenticated"} - } - } - } - }, - }, - }, + responses=response_schemas.not_admin_error, ) async def get_users( user_logged: Annotated[ # pylint: disable=unused-argument @@ -203,6 +152,15 @@ async def get_users( "/user/{email}", summary="Cria ou altera usuário na API.", tags=["Auth"], + response_model=schemas.UsersGetSchema, + responses={ + **response_schemas.not_admin_error, + 422: response_schemas.ValidationErrorResponse.docs( + examples=response_schemas.value_response_example( + "email deve ser igual na url e no json" + ) + ), + }, ) async def create_or_update_user( user_logged: Annotated[ # pylint: disable=unused-argument @@ -259,6 +217,15 @@ async def create_or_update_user( "/user/{email}", summary="Consulta um usuário da API.", tags=["Auth"], + response_model=schemas.UsersGetSchema, + responses={ + **response_schemas.not_admin_error, + 404: response_schemas.NotFoundErrorResponse.docs( + examples=response_schemas.value_response_example( + "Usuário `user1@example.com` não existe" + ) + ), + }, ) async def get_user( user_logged: Annotated[ # pylint: disable=unused-argument @@ -285,6 +252,23 @@ async def get_user( "/user/forgot_password/{email}", summary="Solicita recuperação de acesso à API.", tags=["Auth"], + responses={ + **response_schemas.email_validation_error, + 200: response_schemas.OKMessageResponse.docs( + examples={ + "OK": { + "value": response_schemas.OKMessageResponse( + message="Email enviado!" + ).json(), + }, + } + ), + 404: response_schemas.NotFoundErrorResponse.docs( + examples=response_schemas.value_response_example( + "Usuário `user1@example.com` não existe" + ) + ), + }, ) async def forgot_password( email: str, @@ -312,6 +296,18 @@ async def forgot_password( "/user/reset_password/", summary="Criar nova senha a partir do token de acesso.", tags=["Auth"], + responses={ + 200: response_schemas.OKMessageResponse.docs( + examples={ + "OK": { + "value": response_schemas.OKMessageResponse( + message="Senha do Usuário `user1@example.com` atualizada" + ).json(), + }, + } + ), + 400: response_schemas.BadRequestErrorResponse.docs(), + }, ) async def reset_password( access_token: str, @@ -336,8 +332,16 @@ async def reset_password( "/organizacao/{origem_unidade}/{cod_unidade_autorizadora}" "/plano_entregas/{id_plano_entregas}", summary="Consulta plano de entregas", - response_model=schemas.PlanoEntregasSchema, tags=["plano de entregas"], + response_model=schemas.PlanoEntregasSchema, + responses={ + **response_schemas.outra_unidade_error, + 404: response_schemas.NotFoundErrorResponse.docs( + examples=response_schemas.value_response_example( + "Plano de entregas não encontrado" + ) + ), + }, ) async def get_plano_entrega( user: Annotated[schemas.UsersSchema, Depends(crud_auth.get_current_active_user)], @@ -369,8 +373,9 @@ async def get_plano_entrega( "/organizacao/{origem_unidade}/{cod_unidade_autorizadora}" "/plano_entregas/{id_plano_entregas}", summary="Cria ou substitui plano de entregas", - response_model=schemas.PlanoEntregasSchema, tags=["plano de entregas"], + response_model=schemas.PlanoEntregasSchema, + responses=response_schemas.outra_unidade_error, ) async def create_or_update_plano_entregas( user: Annotated[schemas.UsersSchema, Depends(crud_auth.get_current_active_user)], @@ -458,8 +463,16 @@ async def create_or_update_plano_entregas( "/organizacao/{origem_unidade}/{cod_unidade_autorizadora}" "/plano_trabalho/{id_plano_trabalho}", summary="Consulta plano de trabalho", - response_model=schemas.PlanoTrabalhoSchema, tags=["plano de trabalho"], + response_model=schemas.PlanoTrabalhoSchema, + responses={ + **response_schemas.outra_unidade_error, + 404: response_schemas.NotFoundErrorResponse.docs( + examples=response_schemas.value_response_example( + "Plano de trabalho não encontrado" + ) + ), + }, ) async def get_plano_trabalho( user: Annotated[schemas.UsersSchema, Depends(crud_auth.get_current_active_user)], @@ -491,8 +504,9 @@ async def get_plano_trabalho( "/organizacao/{origem_unidade}/{cod_unidade_autorizadora}" "/plano_trabalho/{id_plano_trabalho}", summary="Cria ou substitui plano de trabalho", - response_model=schemas.PlanoTrabalhoSchema, tags=["plano de trabalho"], + response_model=schemas.PlanoTrabalhoSchema, + responses=response_schemas.outra_unidade_error, ) async def create_or_update_plano_trabalho( user: Annotated[schemas.UsersSchema, Depends(crud_auth.get_current_active_user)], @@ -589,8 +603,16 @@ async def create_or_update_plano_trabalho( "/organizacao/{origem_unidade}/{cod_unidade_autorizadora}" "/{cod_unidade_lotacao}/participante/{matricula_siape}", summary="Consulta um Participante", - response_model=schemas.ParticipanteSchema, tags=["participante"], + response_model=schemas.ParticipanteSchema, + responses={ + **response_schemas.outra_unidade_error, + 404: response_schemas.NotFoundErrorResponse.docs( + examples=response_schemas.value_response_example( + "Participante não encontrado" + ) + ), + }, ) async def get_participante( user: Annotated[schemas.UsersSchema, Depends(crud_auth.get_current_active_user)], @@ -624,8 +646,9 @@ async def get_participante( "/organizacao/{origem_unidade}/{cod_unidade_autorizadora}" "/{cod_unidade_lotacao}/participante/{matricula_siape}", summary="Envia um participante", - response_model=schemas.ParticipanteSchema, tags=["participante"], + response_model=schemas.ParticipanteSchema, + responses=response_schemas.outra_unidade_error, ) async def create_or_update_participante( user: Annotated[schemas.UsersSchema, Depends(crud_auth.get_current_active_user)], diff --git a/src/response_schemas.py b/src/response_schemas.py index 380b506..3e82893 100644 --- a/src/response_schemas.py +++ b/src/response_schemas.py @@ -1,11 +1,14 @@ -"""Esquemas Pydantic para as respostas da API. -""" +"""Esquemas Pydantic para as respostas da API com mensagens técnicas não +relacionadas aos requisitos de negócio.""" from abc import ABC +from typing import Optional from fastapi import status from pydantic import BaseModel +# Classes de respostas de dados da API para mensagens técnicas + class ResponseData(ABC, BaseModel): """Classe abstrata para conter os métodos comuns a todos os modelos @@ -15,24 +18,37 @@ class ResponseData(ABC, BaseModel): @classmethod def get_title(cls): + """Retorna o título da resposta.""" return cls._title.default + @classmethod + def docs(cls, examples: Optional[dict] = None) -> dict: + """Retorna a documentação da resposta para o método, exibida pelo + FastAPI na interface OpenAPI.""" + docs = { + "model": cls, + "description": cls.get_title(), + "content": {"application/json": {}}, + } + if examples is not None: + docs["content"]["application/json"]["examples"] = examples + return docs -class ValidationError(BaseModel): - """Estrutura retornada pelo Pydantic para cada erro de validação.""" - type: str - loc: list[str] - msg: str - ctx: dict +class OKMessageResponse(ResponseData): + """Resposta da API para mensagens bem sucedidas.""" + _status_code = status.HTTP_200_OK + _title = "OK" + message: str -class ValidationErrorResponse(ResponseData): - """Resposta da API para erros de validação.""" - _status_code = status.HTTP_422_UNPROCESSABLE_ENTITY - _title = "Unprocessable Entity" - detail: list[ValidationError] +class BadRequestErrorResponse(ResponseData): + """Resposta da API para erros de autorização.""" + + _status_code = status.HTTP_400_BAD_REQUEST + _title = "Bad request" + detail: str class UnauthorizedErrorResponse(ResponseData): @@ -50,8 +66,82 @@ class ForbiddenErrorResponse(ResponseData): _title = "Forbidden" detail: str -RESPONSE_MODEL_FOR_STATUS_CODE = { - status.HTTP_422_UNPROCESSABLE_ENTITY: ValidationErrorResponse, - status.HTTP_401_UNAUTHORIZED: UnauthorizedErrorResponse, - status.HTTP_403_FORBIDDEN: ForbiddenErrorResponse, + +class NotFoundErrorResponse(ResponseData): + """Resposta da API para erros de permissão.""" + + _status_code = status.HTTP_404_NOT_FOUND + _title = "Not found" + detail: str + + +class ValidationError(BaseModel): + """Estrutura retornada pelo Pydantic para cada erro de validação.""" + + type: str + loc: list[str] + msg: str + ctx: dict + + +class ValidationErrorResponse(ResponseData): + """Resposta da API para erros de validação.""" + + _status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + _title = "Unprocessable Entity" + detail: list[ValidationError] + + +# Funções auxiliares para documentação de possíveis respostas técnicas +# na API. + +# Documentação de respostas comuns a diversos endpoints +not_logged_error = { + 401: UnauthorizedErrorResponse.docs( + examples={"Invalid credentials": {"value": {"detail": "Not authenticated"}}} + ), +} +not_admin_error = { + **not_logged_error, + 403: ForbiddenErrorResponse.docs( + examples={ + "Forbidden": { + "value": {"detail": "Usuário não tem permissões de administrador"} + } + } + ), +} +outra_unidade_error = { + **not_logged_error, + 403: ForbiddenErrorResponse.docs( + examples={ + "Forbidden": { + "value": { + "detail": "Usuário não tem permissão na cod_unidade_autorizadora informada" + } + } + } + ), +} +email_validation_error = { + 422: ValidationErrorResponse.docs( + examples={ + "Invalid email format": { + "value": ValidationErrorResponse( + detail=[ + ValidationError( + type="value_error", + loc=["email"], + msg="value is not a valid email address: " + "An email address must have an @-sign.", + input="my_username", + ctx={"reason": "An email address must have an @-sign."}, + url="https://errors.pydantic.dev/2.8/v/value_error", + ) + ] + ).json() + } + } + ) } +value_response_example = lambda message: {"example": {"value": {"detail": message}}}