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: