Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Activate virtual environment (required before running anything)
source venv/bin/activate

# Install dependencies
pip install -r requirements-app.txt

# Run all tests
pytest

# Run a single test file
pytest tests/modules/create_booking/app/test_create_booking_usecase.py

# Run a single test by name
pytest tests/modules/create_booking/app/test_create_booking_usecase.py::TestCreateBookingUsecase::test_create_booking_valid

# Run tests with coverage
pytest --cov=src
```

Set `STAGE=TEST` in your `.env` file (or environment) for local development — this switches to mock repositories and local DynamoDB config automatically.

## Architecture

This is a **Clean Architecture** Python microservice deployed as AWS Lambda functions behind API Gateway, with DynamoDB as the database. Each Lambda function is a module under `src/modules/`.

### Layer flow (outer → inner)

```
Lambda event → Presenter → Controller → Usecase → Repository Interface
Mock (TEST) or DynamoDB (DEV/PROD)
```

- **Presenter** (`*_presenter.py`): Lambda entry point. Instantiates repo/usecase/controller from `Environments`, wraps the raw Lambda event into `LambdaHttpRequest`, injects `user_from_authorizer` from the API Gateway authorizer context, and returns `LambdaHttpResponse.toDict()`.
- **Controller** (`*_controller.py`): Validates and extracts parameters from the request, calls the usecase, wraps the result in a Viewmodel, and returns an HTTP code object (`Created`, `BadRequest`, etc.).
- **Usecase** (`*_usecase.py`): Business logic. Receives primitive types, raises domain/usecase errors.
- **Viewmodel** (`*_viewmodel.py`): Serializes domain entities to response dicts.
- **Repository interface** (`src/shared/domain/repositories/`): Abstract base classes (`IBookingRepository`, `IReservationRepository`) that define the data contract.
- **Repository implementations** (`src/shared/infra/repositories/`): `*_mock.py` for tests, `*_dynamo.py` for production.

### Environment / repo selection

`Environments.get_envs()` (in `src/shared/environments.py`) reads the `STAGE` env var and returns the correct repository class. When `STAGE=TEST`, mocks are used; otherwise DynamoDB implementations are used. All presenters call this at module load time.

### Authentication

A Lambda Authorizer (`src/shared/authorizer/user_mss_authorizer.py`) validates Bearer tokens against an external User MSS API and injects user data into the API Gateway request context. Controllers receive it via `request.data['user_from_authorizer']` (a dict with `user_id`, role, etc.).

### Key shared paths

| Path | Purpose |
|------|---------|
| `src/shared/domain/entities/` | `Booking` and `Court` domain entities with validation |
| `src/shared/domain/enums/` | `SPORT`, `BOOKING_TYPE`, `STATUS_ENUM` enums |
| `src/shared/helpers/errors/` | `domain_errors`, `usecase_errors`, `controller_errors` — raised by different layers |
| `src/shared/helpers/external_interfaces/` | `LambdaHttpRequest/Response`, HTTP status code wrappers |
| `src/shared/infra/dto/` | DynamoDB ↔ domain entity conversion (`*_dynamo_dto.py`) |
| `src/shared/clients/` | External HTTP clients (e.g., `user_api_client.py`) |
| `iac/` | AWS CDK infrastructure (API Gateway, Lambda, DynamoDB, S3, SSM constructs) |

### Naming conventions

- Files and directories: `snake_case`
- Classes: `PascalCase` with type suffix — `CreateBookingController`, `BookingRepositoryMock`, `IBookingRepository`
- Enums: `UPPER_SNAKE_CASE` with `_ENUM` suffix where applicable
- Tests mirror the `src/` directory structure under `tests/`

### Infrastructure

Defined in `iac/` using AWS CDK (Python). The stack provisions API Gateway, Lambda functions, DynamoDB table, S3 bucket, and SSM parameters. The `STAGE` variable controls deployment target (`DEV`, `HOMOLOG`, `PROD`). Local development uses Docker Compose with DynamoDB Local and MinIO (see `iac/local/`).
28 changes: 27 additions & 1 deletion iac/components/lambda_construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,32 @@ def __init__(
results_cache_ttl=Duration.seconds(0)
)

# Segundo authorizer, para rotas em que o token é opcional.
# Reaproveita o mesmo asset do authorizer obrigatório, mudando só o handler.
optional_authorizer_lambda = lambda_.Function(
self,
id=f"LambdaOptionalUserMssAuthorizer-{self.stack_name}-{self.stage}",
function_name=f"lambda_optional_user_mss_authorizer-{self.stack_name}-{self.stage}"[:63],
code=lambda_.Code.from_asset("../src/shared/authorizer"),
handler="user_mss_authorizer.optional_lambda_handler",
runtime=lambda_.Runtime("python3.13"),
layers=[self.lambda_layer],
environment=environment_variables,
timeout=Duration.seconds(15)
)

# identity_sources=[] só é aceito com results_cache_ttl=0, e é justamente essa
# combinação que faz o API Gateway invocar o authorizer mesmo sem header Authorization.
# Com um TokenAuthorizer, a requisição sem header morre em 401 antes de chegar aqui.
optional_request_authorizer = apigw.RequestAuthorizer(
self,
id=f"RequestOptionalUserMssAuthorizer-{self.stack_name}-{self.stage}",
authorizer_name=f"optional_user_mss_authorizer-{self.stack_name}-{self.stage}",
handler=optional_authorizer_lambda,
identity_sources=[],
results_cache_ttl=Duration.seconds(0)
)

self.create_booking = self.create_lambda_api_gateway_integration(
module_name="create_booking",
method="POST",
Expand Down Expand Up @@ -156,7 +182,7 @@ def __init__(
method="GET",
api_resource=api_gateway_resource,
environment_variables=environment_variables,
authorizer=token_authorizer_lambda
authorizer=optional_request_authorizer
)

self.delete_booking = self.create_lambda_api_gateway_integration(
Expand Down
17 changes: 11 additions & 6 deletions src/modules/get_bookings/app/get_bookings_usecase.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from src.shared.domain.enums.sport import SPORT
from src.shared.domain.enums.type import BOOKING_TYPE
from src.shared.domain.repositories.booking_repository_interface import IBookingRepository
import os
from src.shared.clients.user_api_client import UserAPIClient
from src.shared.helpers.errors.domain_errors import EntityError
from src.shared.helpers.errors.usecase_errors import NoItemsFound, DependantFilter
Expand Down Expand Up @@ -62,12 +61,17 @@ def __call__(self,
raise NoItemsFound('booking filters passed')

owner_list = []
print(f"[DEBUG] requester_role recebido: {requester_role}")

for booking in bookings:
if requester_role == 'ADMIN':
client = self.user_client or UserAPIClient()

if requester_role == 'ADMIN':
client = self.user_client

for booking in bookings:
try:
# Construído sob demanda e reaproveitado: cada UserAPIClient() baixa
# a lista inteira de usuários do user mss.
if client is None:
client = UserAPIClient()

owner = {
'name': client.get_user_name(booking.user_id),
'network_id': client.get_user_network_id(booking.user_id),
Expand All @@ -78,6 +82,7 @@ def __call__(self,
'name': 'Erro de integração',
'network_id': 'Erro de integração',
}

owner_list.append(owner)

return {'bookings': bookings, 'owner': owner_list}
131 changes: 100 additions & 31 deletions src/shared/authorizer/user_mss_authorizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,64 @@
from src.shared.environments import Environments


def _get_authorization_header(headers):
'''
Extracts the Authorization header from a REQUEST authorizer event, case-insensitively.

Args:
headers (dict): The headers received in the event.

Returns:
str | None: The raw header value, or None when it is not present.
'''

if not headers:
return None

for key, value in headers.items():
if key.lower() == "authorization":
return value

return None


def _fetch_user_data(token):
'''
Fetches the user information from the user mss using the given token.

Args:
token (str): The bearer token, already stripped of the "Bearer " prefix.

Returns:
dict: The user data returned by the user mss.

Raises:
Exception: When USER_API_URL is not set or the user mss does not answer with 200.
'''

# Fetch the User Mss enpoint from the environment variables
MSS_USER_API_ENDPOINT = os.environ.get("USER_API_URL")
if not MSS_USER_API_ENDPOINT:
raise Exception("MSS_USER_ENDPOINT environment variable not set")

# Creating a HTTP client
http = urllib3.PoolManager()

# Fetching the user information from the user mss
headers = {"Authorization": f"Bearer {token}"}
response = http.request("GET", MSS_USER_API_ENDPOINT + "get-user", headers=headers)

# Checking if the request was successful
if response.status != 200:
raise Exception("Failed to fetch user information")

# Parsing the user data
return json.loads(response.data.decode("utf-8"))


def lambda_handler(event, context):
"""
This function is used to authorize the user to access the API Gateway.
It uses the Microsoft Graph API to fetch the user information and check if the user is from Maua.
TOKEN authorizer. Requires a valid Bearer token — used by every protected route.

Args:
event (dict): The event data passed to the Lambda function.
Expand All @@ -18,47 +72,62 @@ def lambda_handler(event, context):
dict: The response object containing the policy document.
"""

method_arn = event["methodArn"]

try:

# Fetch the User Mss enpoint from the environment variables
MSS_USER_API_ENDPOINT = os.environ.get("USER_API_URL")
if not MSS_USER_API_ENDPOINT:
raise Exception("MSS_USER_ENDPOINT environment variable not set")
token = event["authorizationToken"].replace("Bearer ", "")
user_data = _fetch_user_data(token)

# Creating a HTTP client
http = urllib3.PoolManager()
return generate_policy(
user_data.get("id", "user"), "Allow", method_arn, {"user": json.dumps(user_data)}
)

# Handling exceptions
except Exception as e:
print(f"Error: {e}")
return generate_policy("user", "Deny", method_arn)

# Extracting the token from the event data
token = event["authorizationToken"].replace("Bearer ", "")

# Fetching the user information from the user mss
methodArn = event["methodArn"]
headers = {"Authorization": f"Bearer {token}"}
response = http.request("GET", MSS_USER_API_ENDPOINT + "get-user", headers=headers)
def optional_lambda_handler(event, context):
"""
REQUEST authorizer with optional authentication.

# Checking if the request was successful
if response.status != 200:
raise Exception("Failed to fetch user information")
Wired to routes that must stay reachable by unauthenticated clients. Because it is a
REQUEST authorizer registered with no identity sources, API Gateway always invokes it,
even when the Authorization header is absent.

# Parsing the user data
user_data = json.loads(response.data.decode("utf-8"))
- No token -> Allow with no user context (the route behaves as if the caller were a STUDENT)
- Valid token -> Allow with the user context, same shape as lambda_handler
- Invalid token -> Deny

print("CHECK BEFORE REGEX")
print(user_data)

policy = generate_policy(
user_data.get("id", "user"), "Allow", methodArn, {"user": json.dumps(user_data)}
)
Args:
event (dict): The event data passed to the Lambda function.
context (object): The context object representing the current invocation.

print(policy)
Returns:
dict: The response object containing the policy document.
"""

return policy
method_arn = event["methodArn"]

# Handling exceptions
authorization_header = _get_authorization_header(event.get("headers"))
token = authorization_header.replace("Bearer ", "").strip() if authorization_header else ""

# No token at all: the caller is anonymous, let it through without user context
if not token:
return generate_policy("anonymous", "Allow", method_arn)

try:
user_data = _fetch_user_data(token)

return generate_policy(
user_data.get("id", "user"), "Allow", method_arn, {"user": json.dumps(user_data)}
)

# A token was sent but it is not valid: this is a real authentication failure
except Exception as e:
print(f"Error: {e}")
methodArn = event["methodArn"]
return generate_policy("user", "Deny", methodArn)
return generate_policy("user", "Deny", method_arn)


def generate_policy(principal_id, effect, method_arn, context=None):
Expand Down
Loading
Loading