diff --git a/api-gateway/app/asgi.py b/api-gateway/app/asgi.py index a0a3629..553384c 100644 --- a/api-gateway/app/asgi.py +++ b/api-gateway/app/asgi.py @@ -74,6 +74,29 @@ def get_application() -> FastAPI: "url": "https://mit-license.org/", } + tags_metadata = [ + { + "name": "Actuator", + "description": "Verifies application's liveliness and readiness.", + }, + { + "name": "Queries", + "description": "A request for data from the system.", + }, + { + "name": "Commands", + "description": "A request to change the state of the system.", + }, + { + "name": "Scheduler", + "description": "Manages meeting's schedule workflows.", + }, + { + "name": "Auth", + "description": "Manages user's validation workflows.", + } + ] + app = FastAPI( title=settings.PROJECT_NAME, description=settings.PROJECT_DESCRIPTION, @@ -83,6 +106,7 @@ def get_application() -> FastAPI: lifespan=lifespan, license_info=license_info, contact=contact, + openapi_tags=tags_metadata, ) log.debug("Add application routes.") diff --git a/api-gateway/app/domain/commands/scheduler_service.py b/api-gateway/app/domain/commands/scheduler_service.py new file mode 100644 index 0000000..82ff54d --- /dev/null +++ b/api-gateway/app/domain/commands/scheduler_service.py @@ -0,0 +1,56 @@ +""" +Scheduler Commands +""" +import datetime + +from pydantic import Field + +from app.domain.schemas import CamelCaseModel + + +class ProposeOption(CamelCaseModel): + """ + Command to propose a meeting option. + """ + + date: datetime.date = Field(description="The date of the meeting.", example="2021-01-01") + hour: int = Field(description="The hour of the meeting.", example="12", max=23, min=0) + minute: int = Field(description="The minute of the meeting.", example="30", max=59, min=0) + + +class ScheduleMeeting(CamelCaseModel): + """ + Command to schedule a meeting. + """ + + organizer: str = Field(description="Responsible for the meeting's username.", example="johndoe") + title: str | None = Field(description="The meeting's title.", example="Coffee with John Doe") + description: str | None = Field(description="The meeting's description.", + example="A meeting to discuss the project.") + location: str | None = Field(description="The meeting's location.", example="Floor 3, Cafeteria") + options: list[ProposeOption] = Field(description="A list of options to schedule the meeting.", min_items=1) + guests: set[str] = Field(description="A list of guests to invite to the meeting.", + example=["frank", "jane"], default_factory=set) + + +class ToggleVoting(CamelCaseModel): + """ + Command to toggle voting on a meeting. + """ + username: str = Field(description="Responsible for the meeting's username.", example="johndoe") + voting: bool = Field(description="Whether voting is enabled", example=True, default=True) + + +class JoinMeeting(CamelCaseModel): + """ + Command to join a meeting. + """ + username: str = Field(description="Username of who wants to join a meeting", example="johndoe") + + +class VoteOption(CamelCaseModel): + """ + Command to vote on a meeting option. + """ + username: str = Field(description="Username of who wants to vote on a meeting option", example="johndoe") + option: ProposeOption = Field(description="The option to vote on.") diff --git a/api-gateway/app/domain/events/scheduler_service.py b/api-gateway/app/domain/events/scheduler_service.py new file mode 100644 index 0000000..0cd8292 --- /dev/null +++ b/api-gateway/app/domain/events/scheduler_service.py @@ -0,0 +1,32 @@ +""" +Events from scheduler service. +""" +import datetime + +from pydantic import Field + +from app.domain.schemas import CamelCaseModel + + +class OptionVoted(CamelCaseModel): + """ + Event emitted when an option is voted. + """ + date: datetime.datetime = Field(description="A tentative date for a meeting") + votes: list[str] = Field(description="A list of usernames that voted for the option.", example=["johndoe"]) + + +class MeetingScheduled(CamelCaseModel): + """ + Event emitted when a meeting is scheduled. + """ + + id: str = Field(description="The meeting's unique identifier.", example="6442ee3291a1304d4c88ffc9") + organizer: str = Field(description="Username of who scheduled the meeting.", example="johndoe") + voting: bool = Field(description="Whether mode voting is enabled.", example=True) + title: str = Field(description="The meeting's title.", example="Sprint Planning") + description: str | None = Field(description="The meeting's description.", example="Planning the next sprint.") + location: str | None = Field(description="The meeting's location.", example="Room 1") + date: datetime.datetime | None = Field(description="The meeting's date.", example="2021-01-01T09:30:00Z") + guests: list[str] = Field(description="A list of guests.", example=["johndoe", "clarasmith"], default_factory=list) + options: list[OptionVoted] = Field(description="A list of options.") diff --git a/api-gateway/app/domain/schemas.py b/api-gateway/app/domain/schemas.py index e7e8e58..9b9b052 100644 --- a/api-gateway/app/domain/schemas.py +++ b/api-gateway/app/domain/schemas.py @@ -35,6 +35,7 @@ class Config(BaseConfig): alias_generator = to_camel allow_population_by_field_name = True allow_arbitrary_types = True + use_enum_values = True anystr_strip_whitespace = True diff --git a/api-gateway/app/entrypoints/v1/scheduler_service.py b/api-gateway/app/entrypoints/v1/scheduler_service.py new file mode 100644 index 0000000..3011f23 --- /dev/null +++ b/api-gateway/app/entrypoints/v1/scheduler_service.py @@ -0,0 +1,204 @@ +""" +Scheduler Service Gateway +""" +from typing import Annotated + +from fastapi import APIRouter, Path, Request, Response +from starlette.status import HTTP_200_OK, HTTP_201_CREATED + +from app.adapters.http_client import AsyncHttpClient +from app.adapters.network import gateway +from app.dependencies import AsyncHttpClientDependency, ServiceProvider +from app.domain.commands.scheduler_service import JoinMeeting, ScheduleMeeting, ToggleVoting, VoteOption +from app.domain.events.scheduler_service import MeetingScheduled +from app.domain.models import Service +from app.domain.schemas import ResponseModel, ResponseModels +from app.service_layer.gateway import api_v1_url, get_service, verify_scheduling_meeting, verify_status, \ + verify_user_existence + +router = APIRouter(prefix="/scheduler-service", tags=["Scheduler"]) + + +@router.get("/schedules", + status_code=HTTP_200_OK, + summary="Finds all schedules", + tags=["Queries"] + ) +async def query_schedules( + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModels[MeetingScheduled]: + """ + Retrieves schedules from the Database. + """ + + service = await get_service(service_name="scheduler", services=services) + + response, code = await gateway(service_url=service.base_url, path=f"{api_v1_url}/schedules", + client=client, method="GET") + + verify_status(response=response, status_code=code) + + return ResponseModels[MeetingScheduled](**response) + + +@router.get("/schedules/{schedule_id}", + status_code=HTTP_200_OK, + summary="Finds schedule by id", + tags=["Queries"] + ) +async def query_schedule_by_id( + schedule_id: Annotated[str, Path(description="The schedule's id.", example="b455f6t63t7")], + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModel[MeetingScheduled]: + """ + Retrieves a specific schedule from the Database. + """ + + service = await get_service(service_name="scheduler", services=services) + + response, code = await gateway(service_url=service.base_url, path=f"{api_v1_url}/schedules/{schedule_id}", + client=client, method="GET") + + verify_status(response=response, status_code=code) + + return ResponseModel[MeetingScheduled](**response) + + +@router.post("/schedules", + status_code=HTTP_201_CREATED, + summary="Creates a schedule", + tags=["Commands"], + ) +async def schedule( + command: ScheduleMeeting, + services: ServiceProvider, + client: AsyncHttpClientDependency, + request: Request, + response: Response, +) -> ResponseModel[MeetingScheduled]: + """ + Schedules a meeting. + """ + + updated_command = await verify_scheduling_meeting(command=command, + service=await get_service(service_name="auth", + services=services), + client=client) + + service_response, status_code = await gateway( + service_url=(await get_service(service_name="scheduler", services=services)).base_url, + path=f"{api_v1_url}/schedules", + client=client, + method="POST", + request_body=updated_command.json() + ) + + verify_status(response=service_response, status_code=status_code, status_codes=[HTTP_201_CREATED]) + + response_body = ResponseModel[MeetingScheduled](**service_response) + + response.headers["Location"] = f"{request.base_url}api/v1/scheduler-service/schedules/{response_body.data.id}" + + return response_body + + +@router.patch("/schedules/{schedule_id}/voting", + status_code=HTTP_200_OK, + summary="Toggles voting on a schedule", + tags=["Commands"], + ) +async def toggle_voting( + schedule_id: Annotated[str, Path(description="The schedule's id.", example="b455f6t63t7")], + command: ToggleVoting, + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModel[MeetingScheduled]: + """ + Toggles voting on a schedule. + """ + service_response, status_code = await gateway( + service_url=(await get_service(service_name="scheduler", services=services)).base_url, + path=f"{api_v1_url}/schedules/{schedule_id}/voting", + client=client, + method="PATCH", + request_body=command.json() + ) + + verify_status(response=service_response, status_code=status_code, status_codes=[HTTP_200_OK]) + + return ResponseModel[MeetingScheduled](**service_response) + + +@router.patch("/schedules/{schedule_id}/relationships/guests", + status_code=HTTP_200_OK, + summary="Adds a guest to a schedule", + tags=["Commands"], + ) +async def join_meeting( + schedule_id: Annotated[str, Path(description="The schedule's id.", example="b455f6t63t7")], + command: JoinMeeting, + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModel[MeetingScheduled]: + """ + Allows a valid user to join a meeting. + """ + return await command_with_user_validation( + path=f"{schedule_id}/relationships/guests", + command=command, + services=services, + client=client, + ) + + +@router.patch("/schedules/{schedule_id}/options", + status_code=HTTP_200_OK, + summary="Votes for an option", + tags=["Commands"], + ) +async def vote_for_option( + schedule_id: Annotated[str, Path(description="The schedule's id.", example="b455f6t63t7")], + command: VoteOption, + services: ServiceProvider, + client: AsyncHttpClientDependency, +) -> ResponseModel[MeetingScheduled]: + return await command_with_user_validation( + path=f"{schedule_id}/options", + command=command, + services=services, + client=client, + ) + + +######################################################################################################################## +# Helper functions +######################################################################################################################## + +async def command_with_user_validation( + path: str, + command: JoinMeeting | VoteOption, + services: list[Service], + client: AsyncHttpClient, + method: str = "PATCH", +) -> ResponseModel[MeetingScheduled]: + """ + Validates the user and then sends the command to the scheduler service. + """ + await verify_user_existence(username=command.username, + client=client, + service=await get_service(service_name="auth", services=services), + ) + + service_response, status_code = await gateway( + service_url=(await get_service(service_name="scheduler", services=services)).base_url, + client=client, + path=f"{api_v1_url}/schedules/{path}", + method=method, + request_body=command.json() + ) + + verify_status(response=service_response, status_code=status_code, status_codes=[HTTP_200_OK]) + + return ResponseModel[MeetingScheduled](**service_response) diff --git a/api-gateway/app/router.py b/api-gateway/app/router.py index 161b455..ddd41dc 100644 --- a/api-gateway/app/router.py +++ b/api-gateway/app/router.py @@ -7,7 +7,7 @@ from fastapi import APIRouter from app.entrypoints import actuator -from app.entrypoints.v1 import auth_service +from app.entrypoints.v1 import auth_service, scheduler_service root_router = APIRouter() api_router_v1 = APIRouter(prefix="/api/v1") @@ -17,3 +17,4 @@ # API Routers api_router_v1.include_router(auth_service.router) +api_router_v1.include_router(scheduler_service.router) diff --git a/api-gateway/app/service_layer/gateway.py b/api-gateway/app/service_layer/gateway.py index 9bb76ef..4181e84 100644 --- a/api-gateway/app/service_layer/gateway.py +++ b/api-gateway/app/service_layer/gateway.py @@ -4,11 +4,14 @@ from typing import Any from fastapi import HTTPException -from starlette.status import HTTP_200_OK, HTTP_503_SERVICE_UNAVAILABLE +from starlette.status import HTTP_200_OK, HTTP_409_CONFLICT, HTTP_503_SERVICE_UNAVAILABLE from app.adapters.http_client import AsyncHttpClient from app.adapters.network import gateway +from app.domain.commands.scheduler_service import ScheduleMeeting +from app.domain.events.auth_service import UserRegistered from app.domain.models import Service +from app.domain.schemas import ResponseModel, ResponseModels api_v1_url = "/api/v1" @@ -72,7 +75,78 @@ async def get_users(users: str, service: Service, client: AsyncHttpClient) -> tu Returns: tuple[dict[str, Any], int]: The response and the status code. """ - params = {"users": users.strip()} if users else None + params = {"usernames": users.strip()} if users else None return await gateway(service_url=service.base_url, path=f"{api_v1_url}/users", query_params=params, client=client, method="GET") + + +async def verify_scheduling_meeting(command: ScheduleMeeting, + service: Service, + client: AsyncHttpClient) -> ScheduleMeeting: + """ + Verify scheduling meeting command by checking if the users exists. + + Updates the command with the verified guests: all non-existing guests are removed. + + Args: + command (ScheduleMeeting): The schedule command. + service (Service): The service. + client (AsyncHttpClient): The Async HTTP Client. + + Returns: + ScheduleMeeting: The schedule command with guests verified. + + Raises: + HTTPException: If the organizer does not exist. + """ + user_set = command.guests.copy() + user_set.add(command.organizer) + + comma_separated_usernames = ", ".join(user_set) + + response, code = await get_users(users=comma_separated_usernames, service=service, client=client) + + verify_status(response=response, status_code=code) + + response_event = ResponseModels[UserRegistered](**response) + + usernames = [user.username for user in response_event.data if user.username in user_set] + + if command.organizer not in usernames: + raise HTTPException(status_code=HTTP_409_CONFLICT, detail="Organizer does not exists.") + + usernames.remove(command.organizer) + + return command.copy(update={"guests": set(usernames)}) + + +async def verify_user_existence(username: str, + service: Service, + client: AsyncHttpClient) -> UserRegistered: + """ + Verifies if a user exists + + Args: + username: to verify + service: service which validates users + client: HTTP client to make requests + + Returns: + UserRegistered: the user if it exists + + Raises: + HTTPException: If the user does not exist, or service error. + """ + response, code = await gateway( + service_url=service.base_url, + path=f"{api_v1_url}/users/{username}", + client=client, + method="GET" + ) + + verify_status(response=response, status_code=code) + + response_event = ResponseModel[UserRegistered](**response) + + return response_event.data diff --git a/api-gateway/app/utils/formatter.py b/api-gateway/app/utils/formatter.py index a8225cf..f210989 100644 --- a/api-gateway/app/utils/formatter.py +++ b/api-gateway/app/utils/formatter.py @@ -1,8 +1,11 @@ """ Text formatter """ - +import json from re import sub +from typing import Any + +from pydantic import BaseModel def to_camel(s: str) -> str: @@ -14,3 +17,16 @@ def to_camel(s: str) -> str: """ s = sub(r"(_|-)+", " ", s).title().replace(" ", "") return "".join([s[0].lower(), s[1:]]) + + +def to_jsonable_dict(model: BaseModel) -> dict[str, Any]: + """ + Converts a pydantic model to a json-encode-able dictionary. + + Args: + model (BaseModel): The model to convert. + + Returns: + dict[str, Any]: The converted model. + """ + return json.loads(model.json(by_alias=True)) diff --git a/api-gateway/app/version.py b/api-gateway/app/version.py index e5eee81..47d5635 100644 --- a/api-gateway/app/version.py +++ b/api-gateway/app/version.py @@ -1,4 +1,4 @@ """ Application Version """ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/api-gateway/tests/e2e/v1/test_scheduler_service.py b/api-gateway/tests/e2e/v1/test_scheduler_service.py new file mode 100644 index 0000000..17d54b0 --- /dev/null +++ b/api-gateway/tests/e2e/v1/test_scheduler_service.py @@ -0,0 +1,411 @@ +""" +Tests for the scheduler service gateway. +""" +import datetime +from typing import Any, Callable +import uuid + +from aioresponses import aioresponses +import pytest +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN, \ + HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, \ + HTTP_503_SERVICE_UNAVAILABLE + +from app.dependencies import get_async_http_client, get_services +from app.domain.commands.scheduler_service import JoinMeeting, ProposeOption, ToggleVoting, VoteOption +from app.domain.events.scheduler_service import MeetingScheduled +from app.domain.models import Service +from app.domain.schemas import ResponseModel +from app.utils.formatter import to_jsonable_dict +from tests.conftest import DependencyOverrider +from tests.mocks import schedule_command_factory, user_registered_factory + +FAKE_SCHEDULER_URL = "http://fake-scheduler-service:8001" +FAKE_AUTH_URL = "http://fake-auth-service:8002" + + +def fake_schedule_response(meeting_id: str | None = None, + organizer: str = "johndoe", + guests: list[str] | None = None) -> dict[str, Any]: + """ + Fake schedule response. + + Returns: + dict[str, Any]: The fake schedule response. + """ + return {"data": { + "id": meeting_id or str(uuid.uuid4()), + "organizer": organizer, + "voting": False, + "title": "Meeting with the team", + "description": "We will discuss the new project", + "location": "Zoom", + "guests": guests or list(), + "options": [ + { + "date": "2023-05-03T23:59:00", + "votes": [] + } + ] + }} + + +class TestSchedulerServiceGateway: + """ + Tests for the scheduler service gateway. + """ + + @pytest.fixture + def fake_web(self): + with aioresponses() as mock: + yield mock + + overrides: dict[Callable, Callable] = { + get_services: lambda: [Service(name="scheduler", base_url=FAKE_SCHEDULER_URL), + Service(name="auth", base_url=FAKE_AUTH_URL), + ], + } + + +class TestSchedulerQueries(TestSchedulerServiceGateway): + + def test_get_schedules(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to get all schedules, and a working service + WHEN the request is made + THEN it should return all schedules. + """ + fake_web.get(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", payload={"data": []}, status=HTTP_200_OK) + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.get("/api/v1/scheduler-service/schedules") + # then + assert response.status_code == HTTP_200_OK + + def test_service_unavailable(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to get all schedules, and a service that is not available + WHEN the request is made + THEN it should return a service unavailable error. + """ + fake_web.get(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", payload={}, status=HTTP_503_SERVICE_UNAVAILABLE) + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.get("/api/v1/scheduler-service/schedules") + # then + assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE + + def test_get_schedule_by_id(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to get a schedule by id, and a working service + WHEN the request is made + THEN it should return the schedule. + """ + fake_web.get(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1", + payload=fake_schedule_response(meeting_id="1"), + status=HTTP_200_OK) + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.get("/api/v1/scheduler-service/schedules/1") + # then + assert response.status_code == HTTP_200_OK + + def test_get_schedule_by_id_not_found(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to get a schedule by id, and a working service + WHEN the request is made + THEN it should return the schedule. + """ + fake_web.get(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1", + payload={"detail": "Not found."}, + status=HTTP_404_NOT_FOUND) + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.get("/api/v1/scheduler-service/schedules/1") + # then + assert response.status_code == HTTP_404_NOT_FOUND + + +class TestSchedulerCommands(TestSchedulerServiceGateway): + + def test_schedule_meeting(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to schedule a meeting, with valid organizer + WHEN the request is made + THEN it should return the schedule. + """ + command = schedule_command_factory(organizer="johndoe") + + fake_web.post(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", + payload=fake_schedule_response(meeting_id="1"), + status=HTTP_201_CREATED) + + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?usernames=johndoe", + payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe"))]}, + status=HTTP_200_OK, + ) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + json = to_jsonable_dict(command) + + with DependencyOverrider(self.overrides): + # when + response = test_client.post("/api/v1/scheduler-service/schedules", json=json) + # then + assert response.status_code == HTTP_201_CREATED + + def test_scheduling_meeting_with_guests(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to schedule a meeting, with valid organizer and guests + WHEN the request is made + THEN it should return the schedule. + """ + guest_1, guest_2, organizer = "carl", "jane", "johndoe" + command = schedule_command_factory(organizer=organizer, guests={guest_1, guest_2}) + + guests = {guest_1, guest_2, organizer} + query = ", ".join(guests) + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?usernames={query}", + payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe")), + to_jsonable_dict(user_registered_factory(username="carl")), + to_jsonable_dict(user_registered_factory(username="jane")) + ] + }, + status=HTTP_200_OK, + ) + + fake_web.post(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", + payload=fake_schedule_response(guests=[guest_1, guest_2]), + status=HTTP_201_CREATED) + + json = to_jsonable_dict(command) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.post("/api/v1/scheduler-service/schedules", json=json) + + response_body = ResponseModel[MeetingScheduled](**response.json()) + # then + assert response.status_code == HTTP_201_CREATED + assert guest_1, guest_2 in response_body.data.guests + + def test_scheduling_meeting_with_guests_not_found(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to schedule a meeting, with valid organizer and not valid guests + WHEN the request is made + THEN it schedules without guests + """ + guest_1, guest_2, organizer = "carl", "jane", "johndoe" + command = schedule_command_factory(organizer=organizer, guests={guest_1, guest_2}) + guests = {guest_1, guest_2, organizer} + + query = ", ".join(guests) + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?usernames={query}", + payload={"data": [to_jsonable_dict(user_registered_factory(username="johndoe")), + ] + }, + status=HTTP_200_OK, + ) + fake_web.post(f"{FAKE_SCHEDULER_URL}/api/v1/schedules", + payload=fake_schedule_response(), + status=HTTP_201_CREATED) + + json = to_jsonable_dict(command) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.post("/api/v1/scheduler-service/schedules", json=json) + + response_body = ResponseModel[MeetingScheduled](**response.json()) + # then + assert response.status_code == HTTP_201_CREATED + assert guest_1, guest_2 not in response_body.data.guests + + def test_scheduling_meeting_with_organizer_not_found(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to schedule a meeting, with invalid organizer + WHEN the request is made + THEN it is a conflict + """ + command = schedule_command_factory(organizer="johndoe") + + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users?usernames=johndoe", + payload={"data": []}, + status=HTTP_200_OK, + ) + + json = to_jsonable_dict(command) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.post("/api/v1/scheduler-service/schedules", json=json) + # then + assert response.status_code == HTTP_409_CONFLICT + + def test_toggle_voting(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to toggle voting + WHEN the request is made + THEN it should return the schedule. + """ + command = ToggleVoting(username="johndoe") + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1/voting", + payload=fake_schedule_response(meeting_id="1"), + status=HTTP_200_OK) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch("/api/v1/scheduler-service/schedules/1/voting", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_200_OK + + def test_toggle_voting_not_found(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to toggle voting for an invalid schedule + WHEN the request is made + THEN it should return NOT FOUND. + """ + command = ToggleVoting(username="johndoe") + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/non-found/voting", + payload={"detail": "Not found"}, + status=HTTP_404_NOT_FOUND) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch("/api/v1/scheduler-service/schedules/non-found/voting", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_404_NOT_FOUND + + def test_toggle_voting_non_organizer(self, test_client, fake_web, aio_http_client): + """ + Given a request to toggle voting for a schedule that the user is not the organizer + WHEN the request is made + THEN it should return FORBIDDEN. + """ + command = ToggleVoting(username="johndoe") + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1/voting", + payload={"detail": "Forbidden"}, + status=HTTP_403_FORBIDDEN) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch("/api/v1/scheduler-service/schedules/1/voting", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_403_FORBIDDEN + + def test_user_joins_meeting(self, test_client, fake_web, aio_http_client): + """ + GIVEN a request to join a meeting + WHEN the request is made + THEN it should return the schedule. + """ + joiner = "clarahill" + meeting_id = "1" + command = JoinMeeting(username=joiner) + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users/{joiner}", + payload={"data": to_jsonable_dict(user_registered_factory(username=joiner))}, + status=HTTP_200_OK, + ) + + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/{meeting_id}/relationships/guests", + payload=fake_schedule_response(meeting_id=meeting_id, guests=[joiner]), + status=HTTP_200_OK) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch(f"/api/v1/scheduler-service/schedules/{meeting_id}/relationships/guests", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_200_OK + + def test_invalid_user_cant_join_meeting(self, test_client, fake_web, aio_http_client): + """ + Given a request to join a meeting with an invalid user + WHEN the request is made + THEN it should return NOT FOUND. + """ + joiner = "clarahill" + meeting_id = "1" + command = JoinMeeting(username=joiner) + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users/{joiner}", + payload={"detail": "Not found"}, + status=HTTP_404_NOT_FOUND, + ) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch(f"/api/v1/scheduler-service/schedules/{meeting_id}/relationships/guests", + json=to_jsonable_dict(command)) + # then + assert response.status_code == HTTP_404_NOT_FOUND + + @pytest.mark.parametrize( + "auth_status, scheduler_status, expected_status", + [ + (HTTP_404_NOT_FOUND, HTTP_404_NOT_FOUND, HTTP_404_NOT_FOUND), + (HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_404_NOT_FOUND), + (HTTP_200_OK, HTTP_200_OK, HTTP_200_OK), + (HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_403_FORBIDDEN), + ], + ) + def test_user_votes_option(self, test_client, fake_web, aio_http_client, + auth_status, scheduler_status, expected_status): + """ + GIVEN a request to vote for an option + WHEN the request is made + THEN it should return the corresponding data. + """ + username = "mark" + option = ProposeOption( + date=datetime.date.today(), + hour=12, + minute=30 + ) + + command = VoteOption(username=username, option=option) + + fake_web.get(f"{FAKE_AUTH_URL}/api/v1/users/{username}", + payload={"data": to_jsonable_dict(user_registered_factory(username=username))}, + status=auth_status, + ) + + fake_web.patch(f"{FAKE_SCHEDULER_URL}/api/v1/schedules/1/options", + payload=fake_schedule_response(meeting_id="1"), + status=scheduler_status) + + self.overrides[get_async_http_client] = lambda: aio_http_client + + with DependencyOverrider(self.overrides): + # when + response = test_client.patch(f"/api/v1/scheduler-service/schedules/1/options", + json=to_jsonable_dict(command)) + # then + assert response.status_code == expected_status diff --git a/api-gateway/tests/integration/service_layer/__init__.py b/api-gateway/tests/integration/service_layer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api-gateway/tests/integration/service_layer/test_gateway.py b/api-gateway/tests/integration/service_layer/test_gateway.py new file mode 100644 index 0000000..402c883 --- /dev/null +++ b/api-gateway/tests/integration/service_layer/test_gateway.py @@ -0,0 +1,178 @@ +""" +Test for the gateway service layer +""" +import json +from typing import Any + +from aioresponses import aioresponses +from fastapi import HTTPException +import pytest +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_202_ACCEPTED, HTTP_404_NOT_FOUND, \ + HTTP_503_SERVICE_UNAVAILABLE + +from app.domain.models import Service +from app.service_layer.gateway import get_users, verify_scheduling_meeting, verify_status +from tests.mocks import schedule_command_factory, user_registered_factory + + +class TestGateway: + + @pytest.fixture + def auth_service(self) -> Service: + return Service(name="auth", base_url="http://fake-auth") + + @pytest.fixture + def fake_web(self): + with aioresponses() as mock: + yield mock + + @pytest.mark.parametrize( + "status_code, payload", + [ + (HTTP_200_OK, {"data": []}), + (HTTP_503_SERVICE_UNAVAILABLE, {"detail": "Service unavailable"}), + ], + ) + @pytest.mark.asyncio + async def test_get_users(self, fake_web, auth_service, aio_http_client, + status_code, payload + ): + """ + GIVEN a request to get all users + WHEN the request is made, and service is in a specific status + THEN it should return corresponding data. + """ + # given + fake_web.get(f"{auth_service.base_url}/api/v1/users", payload=payload, status=status_code) + + # when + response, status_code = await get_users(users="", service=auth_service, client=aio_http_client) + + # then + assert status_code == status_code + assert response == payload + + @pytest.mark.parametrize( + "response_status, valid_values, payload", + [ + (HTTP_503_SERVICE_UNAVAILABLE, [HTTP_200_OK, HTTP_201_CREATED, HTTP_202_ACCEPTED], + {"detail": "Service unavailable"}), + (HTTP_404_NOT_FOUND, [HTTP_200_OK], {"detail": "object not found"}), + ], + ) + @pytest.mark.asyncio + async def test_raises_invalid_status(self, fake_web, auth_service, + valid_values: list[int], response_status: int, payload: dict[str, Any]): + """ + GIVEN a request to get all users + WHEN the request is made, and service is in a specific status + THEN it should return corresponding data. + """ + + # when / then + with pytest.raises(HTTPException): + await verify_status(response=payload, status_codes=valid_values, status_code=response_status) + + @pytest.mark.asyncio + async def test_schedule_command_is_ok(self, fake_web, auth_service, aio_http_client): + """ + GIVEN a schedule command with a valid organizer + WHEN verification is made + THEN it should return the same command. + """ + # given + organizer = "johndoe" + command = schedule_command_factory(organizer=organizer) + json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) + payload = {"data": [json_dict]} + fake_web.get(f"{auth_service.base_url}/api/v1/users?usernames=johndoe", payload=payload, status=HTTP_200_OK) + + # when + updated_command = await verify_scheduling_meeting(command=command, service=auth_service, client=aio_http_client) + + # then + assert command == updated_command + + @pytest.mark.asyncio + async def test_schedule_command_is_ok_with_guests(self, fake_web, auth_service, aio_http_client): + """ + GIVEN a schedule command with a valid organizer and guests + WHEN verification is made + THEN it should return the same command. + """ + # given + organizer = "johndoe" + guest_1 = "janedoe" + guest_2 = "mark" + guests = {guest_1, guest_2} + + command = schedule_command_factory(organizer=organizer, guests=guests) + users = guests.copy() + users.add(organizer) + query = f"usernames={', '.join(users)}" + + json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) + json_dict_2 = json.loads(user_registered_factory(username=guest_1).json(by_alias=True)) + json_dict_3 = json.loads(user_registered_factory(username=guest_2).json(by_alias=True)) + payload = {"data": [json_dict, json_dict_2, json_dict_3]} + + fake_web.get(f"{auth_service.base_url}/api/v1/users?{query}", payload=payload, status=HTTP_200_OK) + + # when + updated_command = await verify_scheduling_meeting(command=command, service=auth_service, client=aio_http_client) + + # then + assert command == updated_command + + @pytest.mark.asyncio + async def test_schedule_command_with_missing_guests(self, fake_web, auth_service, aio_http_client): + """ + GIVEN a schedule command with a valid organizer but invalid guests + WHEN verification is made + THEN only valid guests should be returned. + """ + # given + organizer = "johndoe" + guest_1 = "janedoe" + guest_2 = "mark" + guests = {guest_1, guest_2} + + command = schedule_command_factory(organizer=organizer, guests=guests) + users = guests.copy() + users.add(organizer) + query = f"usernames={', '.join(users)}" + + json_dict = json.loads(user_registered_factory(username=organizer).json(by_alias=True)) + json_dict_2 = json.loads(user_registered_factory(username=guest_1).json(by_alias=True)) + payload = {"data": [json_dict, json_dict_2]} + + fake_web.get(f"{auth_service.base_url}/api/v1/users?{query}", payload=payload, status=HTTP_200_OK) + + # when + updated_command = await verify_scheduling_meeting(command=command, service=auth_service, client=aio_http_client) + + # then + assert command != updated_command + assert guest_2 not in updated_command.guests + + @pytest.mark.asyncio + async def test_schedule_command_with_missing_organizer(self, fake_web, auth_service, aio_http_client): + """ + GIVEN a schedule command an invalid organizer + WHEN verification is made + THEN should raise an exception. + """ + # given + organizer = "johndoe" + + command = schedule_command_factory(organizer=organizer) + + fake_web.get(f"{auth_service.base_url}/api/v1/users?usernames={organizer}", + payload={"data": []}, + status=HTTP_200_OK) + + # when + with pytest.raises(HTTPException): + updated_command = await verify_scheduling_meeting(command=command, + service=auth_service, + client=aio_http_client) diff --git a/api-gateway/tests/mocks.py b/api-gateway/tests/mocks.py new file mode 100644 index 0000000..de05267 --- /dev/null +++ b/api-gateway/tests/mocks.py @@ -0,0 +1,59 @@ +""" +Tests Mocks +""" +import datetime +from typing import Any +from uuid import uuid4 + +from pydantic import EmailStr + +from app.domain.commands.scheduler_service import ProposeOption, ScheduleMeeting +from app.domain.events.auth_service import UserRegistered + + +def user_registered_factory(username: str, user_id: str | None = None) -> UserRegistered: + """ + Creates a UserRegistered event. + + Args: + username (str): The username. + user_id (str, optional): The user id. Defaults to uuid4. + + Returns: + UserRegistered: The UserRegistered with the given username and user id, and a fake email. + """ + return UserRegistered(username=username, id=user_id or str(uuid4()), email=EmailStr(f"{username}@e.mail")) + + +def user_registered_response_factory(username: str, user_id: str | None = None) -> dict[str, Any]: + """ + Creates a UserRegistered response. + + Args: + username (str): The username. + user_id (str, optional): The user id. Defaults to uuid4. + + Returns: + dict[str, Any]: The UserRegistered response with the given username and user id, and a fake email. + """ + + return { + "data": user_registered_factory(username, user_id).dict(by_alias=True) + } + + +def schedule_command_factory(organizer: str, + title: str = "Test Meeting", guests: set[str] | None = None) -> ScheduleMeeting: + """ + Creates a schedule command. + + Args: + organizer (str): The organizer. + title (str, optional): The title. Defaults to "Test Meeting". + guests (set[str], optional): The guests. Defaults to None. + + Returns: + dict[str, Any]: The schedule command. + """ + fake_options = [ProposeOption(date=datetime.date.today(), hour=12, minute=30)] + return ScheduleMeeting(organizer=organizer, title=title, guests=guests or set(), options=fake_options) diff --git a/pom.xml b/pom.xml index 6a42c1b..a90714f 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ schedutn pom 0.2.0-SNAPSHOT - + scheduler @@ -84,10 +84,6 @@ org.springframework.boot spring-boot-starter-actuator - - spring-boot-starter-data-mongodb - org.springframework.boot - org.springframework.boot spring-boot-starter-security diff --git a/scheduler/src/main/java/com/schedutn/scheduler/SchedulerApplication.java b/scheduler/src/main/java/com/schedutn/scheduler/SchedulerApplication.java index 257686f..1284b55 100644 --- a/scheduler/src/main/java/com/schedutn/scheduler/SchedulerApplication.java +++ b/scheduler/src/main/java/com/schedutn/scheduler/SchedulerApplication.java @@ -1,8 +1,24 @@ package com.schedutn.scheduler; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +@OpenAPIDefinition( + info = @io.swagger.v3.oas.annotations.info.Info( + title = "Scheduler Service", + version = "0.1.1", + description = "Scheduler manages the workflow of meetings scheduling", + license = @io.swagger.v3.oas.annotations.info.License( + name = "MIT", + url = "https://mit-license.org/" + ), + contact = @io.swagger.v3.oas.annotations.info.Contact( + name = "Tomas Sanchez", + url = "https://tomasanchez.github.io", + email = "tosanchez@frba.utn.edu.ar") + ) +) @SpringBootApplication public class SchedulerApplication { diff --git a/scheduler/src/main/java/com/schedutn/scheduler/api/GlobalControllerExceptionHandler.kt b/scheduler/src/main/java/com/schedutn/scheduler/api/GlobalControllerExceptionHandler.kt index 6108f55..ec50e0d 100644 --- a/scheduler/src/main/java/com/schedutn/scheduler/api/GlobalControllerExceptionHandler.kt +++ b/scheduler/src/main/java/com/schedutn/scheduler/api/GlobalControllerExceptionHandler.kt @@ -1,5 +1,6 @@ package com.schedutn.scheduler.api +import com.schedutn.scheduler.api.errors.ErrorWrapper import com.schedutn.scheduler.api.errors.InvalidSchedule import com.schedutn.scheduler.api.errors.UnAuthorizedScheduleOperation import com.schedutn.scheduler.service.ScheduleAuthorizationException @@ -29,28 +30,30 @@ class GlobalControllerExceptionHandler : ResponseEntityExceptionHandler() { @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(ScheduleNotFoundException::class) fun handleScheduleNotFound( - ex: ScheduleNotFoundException): ResponseEntity { + ex: ScheduleNotFoundException): ResponseEntity> { val details = InvalidSchedule( - code = "404", + code = "${HttpStatus.NOT_FOUND}", message = ex.message ?: "Not found" ) - log.error("404: $details") - return ResponseEntity(details, HttpStatus.NOT_FOUND) + log.error("${HttpStatus.NOT_FOUND}: $details") + return ResponseEntity(ErrorWrapper(detail = details), HttpStatus.NOT_FOUND) } @ResponseStatus(HttpStatus.FORBIDDEN) @ExceptionHandler(ScheduleAuthorizationException::class) fun handleScheduleAuthorization( - ex: ScheduleAuthorizationException): ResponseEntity { + ex: ScheduleAuthorizationException): ResponseEntity> { - val bodyOfResponse = UnAuthorizedScheduleOperation( - code = "403", + val error = UnAuthorizedScheduleOperation( + code = "${HttpStatus.FORBIDDEN}", message = ex.message ?: "Forbidden" ) - - return ResponseEntity(bodyOfResponse, HttpStatus.FORBIDDEN) + + return ResponseEntity(ErrorWrapper( + detail = error, + ), HttpStatus.FORBIDDEN) } /** @@ -60,9 +63,9 @@ class GlobalControllerExceptionHandler : ResponseEntityExceptionHandler() { * @return a response entity with the occurred errors and an unprocessable entity status */ override fun handleMethodArgumentNotValid(ex: MethodArgumentNotValidException, - headers: HttpHeaders, - status: HttpStatusCode, - request: WebRequest): ResponseEntity { + headers: HttpHeaders, + status: HttpStatusCode, + request: WebRequest): ResponseEntity { val errors: MutableMap = HashMap() diff --git a/scheduler/src/main/java/com/schedutn/scheduler/api/errors/ErrorWrapper.kt b/scheduler/src/main/java/com/schedutn/scheduler/api/errors/ErrorWrapper.kt new file mode 100644 index 0000000..6d736da --- /dev/null +++ b/scheduler/src/main/java/com/schedutn/scheduler/api/errors/ErrorWrapper.kt @@ -0,0 +1,18 @@ +package com.schedutn.scheduler.api.errors + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "Error wrapper") +@JsonInclude(JsonInclude.Include.NON_NULL) +class ErrorWrapper( + @Schema(description = "Error details") + @JsonProperty("detail") + val detail: T + +) { + @Schema(description = "Response metadata") + @JsonProperty("meta") + val meta: Map? = null +} \ No newline at end of file diff --git a/scheduler/src/main/java/com/schedutn/scheduler/api/v1/SchedulesEntryPoint.kt b/scheduler/src/main/java/com/schedutn/scheduler/api/v1/SchedulesEntryPoint.kt index 81c6603..1b4b881 100644 --- a/scheduler/src/main/java/com/schedutn/scheduler/api/v1/SchedulesEntryPoint.kt +++ b/scheduler/src/main/java/com/schedutn/scheduler/api/v1/SchedulesEntryPoint.kt @@ -3,6 +3,7 @@ package com.schedutn.scheduler.api.v1 import com.schedutn.scheduler.api.DataWrapper import com.schedutn.scheduler.api.v1.SchedulesEntryPoint.Companion.SCHEDULES_ENTRY_POINT_URL +import com.schedutn.scheduler.domain.commands.JoinMeeting import com.schedutn.scheduler.domain.commands.ScheduleMeeting import com.schedutn.scheduler.domain.commands.ToggleVoting import com.schedutn.scheduler.domain.commands.VoteForOption @@ -14,7 +15,6 @@ import jakarta.validation.Valid import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.http.ResponseEntity -import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.bind.annotation.* import org.springframework.web.servlet.support.ServletUriComponentsBuilder import java.net.URI @@ -93,7 +93,7 @@ class SchedulesEntryPoint { tags = ["Commands"] ) fun toggleVoting(@PathVariable id: String, - @Valid @RequestBody command: ToggleVoting + @Valid @RequestBody command: ToggleVoting ): DataWrapper { log.info("Toggling voting for schedule with id: $id") @@ -110,7 +110,7 @@ class SchedulesEntryPoint { tags = ["Commands"] ) fun voteForOption(@PathVariable id: String, - @Valid @RequestBody command: VoteForOption + @Valid @RequestBody command: VoteForOption ): DataWrapper { log.info("Voting for option for schedule with id: $id") @@ -118,21 +118,20 @@ class SchedulesEntryPoint { return DataWrapper(data = schedule) } - @PostMapping("/{id}/relationships/guests") + @PatchMapping("/{id}/relationships/guests") @ResponseStatus(org.springframework.http.HttpStatus.OK) @Operation( summary = "Commands to Join a Meeting", description = "Adds a guest to a meeting", tags = ["Commands"] ) - fun joinMeeting(@PathVariable id: String): DataWrapper { + fun joinMeeting(@PathVariable id: String, @Valid @RequestBody command: JoinMeeting): DataWrapper { log.info("Joining meeting for schedule with id: $id") - val auth = SecurityContextHolder.getContext().authentication.principal.toString() - val joined = service.joinAMeeting(id = id, username = auth) + val joined = service.joinAMeeting(id = id, username = command.username) - log.info("$auth joined meeting for schedule with id: $id") + log.info("${command.username} joined meeting for schedule with id: $id") return DataWrapper(data = joined) } diff --git a/scheduler/src/main/java/com/schedutn/scheduler/domain/commands/JoinMeeting.kt b/scheduler/src/main/java/com/schedutn/scheduler/domain/commands/JoinMeeting.kt new file mode 100644 index 0000000..3675429 --- /dev/null +++ b/scheduler/src/main/java/com/schedutn/scheduler/domain/commands/JoinMeeting.kt @@ -0,0 +1,19 @@ +package com.schedutn.scheduler.domain.commands + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +/** + * Join meeting command. + * + * @property username + */ +@Schema(description = "Command to Join a meeting") +data class JoinMeeting( + + @JsonProperty("username") + @Schema(description = "User's username", example = "johndoe", required = true) + @field:NotBlank + val username: String, +) : Command diff --git a/scheduler/src/main/java/com/schedutn/scheduler/service/MeetingScheduler.kt b/scheduler/src/main/java/com/schedutn/scheduler/service/MeetingScheduler.kt index fc63c20..96e6d21 100644 --- a/scheduler/src/main/java/com/schedutn/scheduler/service/MeetingScheduler.kt +++ b/scheduler/src/main/java/com/schedutn/scheduler/service/MeetingScheduler.kt @@ -49,8 +49,7 @@ class MeetingScheduler : ScheduleService { } override fun toggleVoting(id: String, command: ToggleVoting): MeetingScheduled { - val schedule = schedules.findById(id) ?: throw IllegalArgumentException( - "Schedule not found") + val schedule = schedules.findById(id) ?: throw ScheduleNotFoundException(id) try { val toggled = schedule.toggleVoting(username = command.username, @@ -72,7 +71,7 @@ class MeetingScheduler : ScheduleService { hour = command.option.hour, minute = command.option.minute, ) - + try { val voted: Schedule = schedule.vote(option = option, username = command.username) diff --git a/scheduler/src/main/resources/application.yml b/scheduler/src/main/resources/application.yml index 1b31e76..ca26101 100644 --- a/scheduler/src/main/resources/application.yml +++ b/scheduler/src/main/resources/application.yml @@ -1,5 +1,5 @@ server: - port: 8080 + port: 8001 spring: application: