From b607bfccb8bea672f44649e6f31d96f624e3cdcc Mon Sep 17 00:00:00 2001 From: Amirul Islam Date: Thu, 5 Dec 2024 18:22:58 +0100 Subject: [PATCH 1/5] Refactoring 2.0: Improved Project Structure and Exception Handling - Restructured the project to align better with OpenAPI specifications and best practices. - Split `/src/core/exceptions.py` into modular components: `api.py`, `decorators.py`, and `resource.py`. - Enhanced `ContactNotFoundError` and `MessageNotFoundError` handling for robust SDK operations. - Updated and optimized unit tests for `contacts` and `messages` modules with improved error handling. - Revised the directory structure for better readability and maintainability. - Updated documentation to reflect the latest project structure and design changes. --- src/common/exceptions.py | 121 ------------------ src/{common => core}/__init__.py | 0 src/core/exceptions/__init__.py | 16 +++ src/core/exceptions/api.py | 58 +++++++++ src/core/exceptions/decorators.py | 59 +++++++++ src/core/exceptions/resource.py | 29 +++++ src/{common => core}/logger.py | 0 src/{common => core}/requests.py | 0 src/{common => core}/retry.py | 0 src/core/security.py | 60 +++++++++ src/{common => core}/validators.py | 54 -------- src/{sdk => }/schemas/__init__.py | 0 src/{sdk => }/schemas/contacts.py | 0 src/schemas/errors.py | 75 +++++++++++ src/{sdk => }/schemas/messages.py | 0 src/{server/schemas.py => schemas/webhook.py} | 0 src/sdk/client.py | 8 +- src/sdk/features/contacts.py | 29 +++-- src/sdk/features/messages.py | 17 ++- src/server/app.py | 33 +++-- 20 files changed, 353 insertions(+), 206 deletions(-) delete mode 100644 src/common/exceptions.py rename src/{common => core}/__init__.py (100%) create mode 100644 src/core/exceptions/__init__.py create mode 100644 src/core/exceptions/api.py create mode 100644 src/core/exceptions/decorators.py create mode 100644 src/core/exceptions/resource.py rename src/{common => core}/logger.py (100%) rename src/{common => core}/requests.py (100%) rename src/{common => core}/retry.py (100%) create mode 100644 src/core/security.py rename src/{common => core}/validators.py (56%) rename src/{sdk => }/schemas/__init__.py (100%) rename src/{sdk => }/schemas/contacts.py (100%) create mode 100644 src/schemas/errors.py rename src/{sdk => }/schemas/messages.py (100%) rename src/{server/schemas.py => schemas/webhook.py} (100%) diff --git a/src/common/exceptions.py b/src/common/exceptions.py deleted file mode 100644 index 6134ee6..0000000 --- a/src/common/exceptions.py +++ /dev/null @@ -1,121 +0,0 @@ -from .logger import logger - - -class ApiError(Exception): - """ - Base exception class for all API-related errors. - - Attributes: - message (str): The error message describing the issue. - status_code (int, optional): HTTP status code associated with the error (if applicable). - """ - - def __init__(self, message: str, status_code: int = None): - super().__init__(message) - self.message = message - self.status_code = status_code - if status_code: - logger.error(f"[API Error] {status_code}: {message}") - else: - logger.error(f"[API Error]: {message}") - - def __str__(self): - return f"{self.message} (HTTP {self.status_code})" if self.status_code else self.message - - -class UnauthorizedError(ApiError): - """ - Exception raised for 401 Unauthorized errors. - """ - - def __init__(self, message: str = "Unauthorized access. Check your API key."): - super().__init__(message, status_code=401) - logger.warning("[UnauthorizedError] Ensure your API key is valid.") - - -class NotFoundError(ApiError): - """ - Exception raised for 404 Not Found errors. - """ - - def __init__(self, message: str = "Requested resource not found."): - super().__init__(message, status_code=404) - logger.warning("[NotFoundError] Resource does not exist.") - - -class ServerError(ApiError): - """ - Exception raised for 500+ Server Error responses. - """ - - def __init__(self, message: str = "Internal server error. Please try again later.", status_code: int = 500): - super().__init__(message, status_code=status_code) - logger.error("[ServerError] The server encountered an issue.") - - -class PayloadValidationError(ApiError): - """ - Exception raised for validation errors in payloads or responses. - - Attributes: - errors (list, optional): List of validation errors with field-level details. - """ - - def __init__(self, message: str = "Validation failed.", errors: list = None): - super().__init__(message) - self.errors = errors or [] - logger.error(f"[PayloadValidationError] {message}") - if self.errors: - for error in self.errors: - logger.error(f"Validation Detail: Field - {error['loc']}, Error - {error['msg']}") - - -class RateLimitError(ApiError): - """ - Exception raised for 429 Rate Limit Exceeded responses. - """ - - def __init__(self, message: str = "Rate limit exceeded. Please wait and retry."): - super().__init__(message, status_code=429) - logger.warning("[RateLimitError] API rate limit reached. Retry after some time.") - - -class TransientError(ApiError): - """ - Exception raised for transient server errors like 502 Bad Gateway or 503 Service Unavailable. - """ - - def __init__(self, message: str = "Transient server error. Please retry.", status_code: int = None): - super().__init__(message, status_code=status_code) - if status_code: - logger.warning(f"[TransientError] {message} (HTTP {status_code})") - else: - logger.warning(f"[TransientError] {message}") - - -def handle_exceptions(func): - """ - Decorator to handle exceptions consistently across the SDK. - - Args: - func (Callable): The function to wrap. - - Returns: - Callable: The wrapped function with exception handling. - - Raises: - ApiError: Reraises known API errors for logging and debugging. - RuntimeError: Raises unexpected errors as runtime exceptions. - """ - - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except ApiError as api_error: - logger.error(f"[ApiError]: {api_error}") - raise - except Exception as unexpected_error: - logger.error(f"[Unhandled Exception]: {unexpected_error}") - raise RuntimeError(f"An unexpected error occurred: {unexpected_error}") - - return wrapper diff --git a/src/common/__init__.py b/src/core/__init__.py similarity index 100% rename from src/common/__init__.py rename to src/core/__init__.py diff --git a/src/core/exceptions/__init__.py b/src/core/exceptions/__init__.py new file mode 100644 index 0000000..0ed5a3e --- /dev/null +++ b/src/core/exceptions/__init__.py @@ -0,0 +1,16 @@ +from .api import ApiError, UnauthorizedError, NotFoundError, ServerError, TransientError +from .resource import ContactNotFoundError, MessageNotFoundError, ResourceNotFoundError +from .decorators import handle_exceptions, handle_404_error + +__all__ = [ + "ApiError", + "UnauthorizedError", + "NotFoundError", + "ServerError", + "TransientError", + "ContactNotFoundError", + "MessageNotFoundError", + "ResourceNotFoundError", + "handle_exceptions", + "handle_404_error", +] diff --git a/src/core/exceptions/api.py b/src/core/exceptions/api.py new file mode 100644 index 0000000..cc8600d --- /dev/null +++ b/src/core/exceptions/api.py @@ -0,0 +1,58 @@ +from src.core.logger import logger + + +class ApiError(Exception): + """ + Base exception class for all API-related errors. + + Attributes: + message (str): The error message describing the issue. + status_code (int, optional): HTTP status code associated with the error (if applicable). + """ + + def __init__(self, message: str, status_code: int = None): + super().__init__(message) + self.message = message + self.status_code = status_code + if status_code: + logger.error(f"[API Error] {status_code}: {message}") + else: + logger.error(f"[API Error]: {message}") + + def __str__(self): + return f"{self.message} (HTTP {self.status_code})" if self.status_code else self.message + + +class UnauthorizedError(ApiError): + """Exception raised for 401 Unauthorized errors.""" + + def __init__(self, message: str = "Unauthorized access. Check your API key."): + super().__init__(message, status_code=401) + logger.warning("[UnauthorizedError] Ensure your API key is valid.") + + +class NotFoundError(ApiError): + """Exception raised for 404 Not Found errors.""" + + def __init__(self, message: str = "Requested resource not found."): + super().__init__(message, status_code=404) + logger.warning("[NotFoundError] Resource does not exist.") + + +class ServerError(ApiError): + """Exception raised for 500+ Server Error responses.""" + + def __init__(self, message: str = "Internal server error. Please try again later.", status_code: int = 500): + super().__init__(message, status_code=status_code) + logger.error("[ServerError] The server encountered an issue.") + + +class TransientError(ApiError): + """Exception raised for transient server errors like 502 Bad Gateway or 503 Service Unavailable.""" + + def __init__(self, message: str = "Transient server error. Please retry.", status_code: int = None): + super().__init__(message, status_code=status_code) + if status_code: + logger.warning(f"[TransientError] {message} (HTTP {status_code})") + else: + logger.warning(f"[TransientError] {message}") diff --git a/src/core/exceptions/decorators.py b/src/core/exceptions/decorators.py new file mode 100644 index 0000000..c0863bf --- /dev/null +++ b/src/core/exceptions/decorators.py @@ -0,0 +1,59 @@ +from httpx import HTTPStatusError +from src.core.logger import logger +from .resource import ContactNotFoundError, MessageNotFoundError +from .api import ApiError + + +def handle_exceptions(func): + """ + Decorator to handle exceptions consistently across the SDK. + + Args: + func (Callable): The function to wrap. + + Returns: + Callable: The wrapped function with exception handling. + + Raises: + ApiError: Reraises known API errors for logging and debugging. + RuntimeError: Raises unexpected errors as runtime exceptions. + """ + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ApiError as api_error: + logger.error(f"[ApiError]: {api_error}") + raise + except Exception as unexpected_error: + logger.error(f"[Unhandled Exception]: {unexpected_error}") + raise RuntimeError(f"An unexpected error occurred: {unexpected_error}") + + return wrapper + + +def handle_404_error(e: HTTPStatusError, resource_id: str, resource_type: str) -> None: + """ + Handle 404 errors for specific resources. + + Args: + e (HTTPStatusError): The HTTP exception. + resource_id (str): The ID of the missing resource. + resource_type (str): The type of the resource (e.g., "Contact", "Message"). + + Raises: + ResourceNotFoundError: A resource-specific not found error. + """ + if e.response.status_code == 404: + error_details = e.response.json() + if resource_type == "Contact": + raise ContactNotFoundError( + resource_id, + message=error_details.get("message", f"Contact with ID {resource_id} not found."), + ) + elif resource_type == "Message": + raise MessageNotFoundError( + resource_id, + message=error_details.get("message", f"Message with ID {resource_id} not found."), + ) + raise # Re-raise other HTTP errors diff --git a/src/core/exceptions/resource.py b/src/core/exceptions/resource.py new file mode 100644 index 0000000..35d24c4 --- /dev/null +++ b/src/core/exceptions/resource.py @@ -0,0 +1,29 @@ +from src.core.exceptions.api import ApiError + + +class ResourceNotFoundError(ApiError): + """ + Base exception for resource not found errors. + + Attributes: + resource_id (str): The ID of the missing resource. + resource_name (str): The name of the resource type (e.g., "Contact", "Message"). + """ + + def __init__(self, id: str, resource_name: str, message: str = None): + message = message or f"{resource_name} with ID {id} not found." + super().__init__(message, status_code=404) + self.id = id + self.resource_name = resource_name + + +class ContactNotFoundError(ResourceNotFoundError): + """Exception for Contact not found.""" + def __init__(self, id: str, message: str = "Contact not found."): + super().__init__(id=id, resource_name="Contact", message=message) + + +class MessageNotFoundError(ResourceNotFoundError): + """Exception for Message not found.""" + def __init__(self, id: str, message: str = "Message not found."): + super().__init__(id=id, resource_name="Message", message=message) diff --git a/src/common/logger.py b/src/core/logger.py similarity index 100% rename from src/common/logger.py rename to src/core/logger.py diff --git a/src/common/requests.py b/src/core/requests.py similarity index 100% rename from src/common/requests.py rename to src/core/requests.py diff --git a/src/common/retry.py b/src/core/retry.py similarity index 100% rename from src/common/retry.py rename to src/core/retry.py diff --git a/src/core/security.py b/src/core/security.py new file mode 100644 index 0000000..8cb03e8 --- /dev/null +++ b/src/core/security.py @@ -0,0 +1,60 @@ +import hmac +import json +import hashlib + +from functools import wraps +from .logger import logger +from src.schemas.errors import UnauthorizedError + + +def generate_signature(payload: dict, secret: str) -> str: + """ + Generate HMAC signature for a given payload. + + Args: + payload (dict): The payload to sign. + secret (str): The secret key. + + Returns: + str: Hexadecimal HMAC signature. + """ + try: + # Serialize payload to JSON with stable formatting + message = json.dumps(payload, separators=(",", ":")).encode("utf-8") # Convert to bytes + hmac_instance = hmac.new(secret.encode("utf-8"), message, hashlib.sha256) + return hmac_instance.hexdigest() + except Exception as e: + raise ValueError(f"Error generating signature: {str(e)}") + + +def verify_signature(message: bytes, signature: str, secret: str): + """ + Validate the HMAC signature of incoming webhooks. + + Args: + message (bytes): Raw request body in bytes. + signature (str): Authorization header signature. + secret (str): Webhook secret. + + Raises: + UnauthorizedError: If the signature is invalid. + """ + logger.info("Validating HMAC signature.") + try: + # Create HMAC using secret and SHA256 + hmac_instance = hmac.new(secret.encode("utf-8"), message, hashlib.sha256) + expected_signature = hmac_instance.hexdigest() + + # Validate the signature + if not hmac.compare_digest(expected_signature, signature): + logger.error("Invalid HMAC signature.") + raise UnauthorizedError( + message="Unauthorized: Signature validation failed." + ) + + logger.info("HMAC signature validated successfully.") + return True + + except Exception as e: + logger.error(f"Error validating signature: {str(e)}") + raise diff --git a/src/common/validators.py b/src/core/validators.py similarity index 56% rename from src/common/validators.py rename to src/core/validators.py index 0701bdf..0a2d4a9 100644 --- a/src/common/validators.py +++ b/src/core/validators.py @@ -1,7 +1,3 @@ -import hmac -import json -import hashlib - from pydantic import ValidationError from functools import wraps from typing import Any, Callable @@ -55,53 +51,3 @@ def wrapper(*args, **kwargs): raise ValueError(f"Invalid response: {e}") return wrapper return decorator - - -def generate_signature(payload: dict, secret: str) -> str: - """ - Generate HMAC signature for a given payload. - - Args: - payload (dict): The payload to sign. - secret (str): The secret key. - - Returns: - str: Hexadecimal HMAC signature. - """ - try: - # Serialize payload to JSON with stable formatting - message = json.dumps(payload, separators=(",", ":")).encode("utf-8") # Convert to bytes - hmac_instance = hmac.new(secret.encode("utf-8"), message, hashlib.sha256) - return hmac_instance.hexdigest() - except Exception as e: - raise ValueError(f"Error generating signature: {str(e)}") - - -def verify_signature(message: bytes, signature: str, secret: str): - """ - Validate the HMAC signature of incoming webhooks. - - Args: - message (bytes): Raw request body in bytes. - signature (str): Authorization header signature. - secret (str): Webhook secret. - - Raises: - ValueError: If the signature is invalid. - """ - logger.info("Validating HMAC signature.") - try: - # Create HMAC using secret and SHA256 - hmac_instance = hmac.new(secret.encode("utf-8"), message, hashlib.sha256) - expected_signature = hmac_instance.hexdigest() - - # Validate the signature - if not hmac.compare_digest(expected_signature, signature): - raise ValueError("Invalid signature.") - - logger.info("HMAC signature validated successfully.") - return True - - except Exception as e: - logger.error(f"Error validating signature: {str(e)}") - raise ValueError("Signature validation failed.") diff --git a/src/sdk/schemas/__init__.py b/src/schemas/__init__.py similarity index 100% rename from src/sdk/schemas/__init__.py rename to src/schemas/__init__.py diff --git a/src/sdk/schemas/contacts.py b/src/schemas/contacts.py similarity index 100% rename from src/sdk/schemas/contacts.py rename to src/schemas/contacts.py diff --git a/src/schemas/errors.py b/src/schemas/errors.py new file mode 100644 index 0000000..75b9324 --- /dev/null +++ b/src/schemas/errors.py @@ -0,0 +1,75 @@ +from pydantic import BaseModel, Field + + +class BadRequestError(BaseModel): + """ + Schema for Bad Request Error (400). + """ + error: str = Field( + ..., + description="Detailed error message.", + json_schema_extra={"example": "Invalid input data."} + ) + + +class UnauthorizedError(Exception): + """ + Custom exception for unauthorized access. + """ + def __init__(self, message="Unauthorized access"): + super().__init__(message) + self.message = message + + +class UnauthorizedErrorSchema(BaseModel): + """ + Schema for Unauthorized Error (401). + """ + message: str = Field( + ..., + description="Authorization error message.", + json_schema_extra={"example": "Unauthorized access."} + ) + + +class ServerError(BaseModel): + """ + Schema for Internal Server Error (500). + """ + message: str = Field( + ..., + description="Server error message.", + json_schema_extra={"example": "An unexpected error occurred."} + ) + + +class MessageNotFoundError(BaseModel): + """ + Schema for Message Not Found Error (404) specific to messages. + """ + id: str = Field( + ..., + description="The ID of the missing message.", + json_schema_extra={"example": "msg123"} + ) + message: str = Field( + ..., + description="Error message.", + json_schema_extra={"example": "Message not found."} + ) + + +class ContactNotFoundError(BaseModel): + """ + Schema for Contact Not Found Error (404) specific to contacts. + """ + id: str = Field( + ..., + description="The ID of the missing contact.", + json_schema_extra={"example": "contact456"} + ) + message: str = Field( + ..., + description="Error message.", + json_schema_extra={"example": "Contact not found."} + ) diff --git a/src/sdk/schemas/messages.py b/src/schemas/messages.py similarity index 100% rename from src/sdk/schemas/messages.py rename to src/schemas/messages.py diff --git a/src/server/schemas.py b/src/schemas/webhook.py similarity index 100% rename from src/server/schemas.py rename to src/schemas/webhook.py diff --git a/src/sdk/client.py b/src/sdk/client.py index 2cd1fda..275c44f 100644 --- a/src/sdk/client.py +++ b/src/sdk/client.py @@ -1,10 +1,10 @@ import requests from typing import Any from config import settings -from src.common.logger import logger -from ..common.requests import handle_request_errors -from ..common.exceptions import UnauthorizedError, NotFoundError, ServerError, ApiError, TransientError -from ..common.retry import retry +from src.core.logger import logger +from src.core.requests import handle_request_errors +from src.core.exceptions import UnauthorizedError, NotFoundError, ServerError, ApiError, TransientError +from src.core.retry import retry class ApiClient: diff --git a/src/sdk/features/contacts.py b/src/sdk/features/contacts.py index b61ce46..ac9867c 100644 --- a/src/sdk/features/contacts.py +++ b/src/sdk/features/contacts.py @@ -1,9 +1,11 @@ from typing import Dict, List +from httpx import HTTPStatusError + from ..client import ApiClient -from ..schemas.contacts import CreateContactRequest, Contact, ListContactsResponse -from src.common.validators import validate_request, validate_response -from src.common.exceptions import handle_exceptions -from src.common.logger import logger +from src.schemas.contacts import CreateContactRequest, Contact, ListContactsResponse +from src.core.validators import validate_request, validate_response +from src.core.exceptions import handle_exceptions, handle_404_error +from src.core.logger import logger class Contacts: @@ -69,7 +71,10 @@ def get_contact(self, contact_id: str) -> Contact: Contact: The retrieved contact details. """ logger.info(f"Fetching contact with ID: {contact_id}") - return self.client.request("GET", f"/contacts/{contact_id}") + try: + return self.client.request("GET", f"/contacts/{contact_id}") + except HTTPStatusError as e: + handle_404_error(e, contact_id, "Contact") @validate_request(CreateContactRequest) @validate_response(Contact) @@ -86,7 +91,10 @@ def update_contact(self, contact_id: str, payload: Dict) -> Contact: Contact: The updated contact details. """ logger.info(f"Updating contact {contact_id} with payload: {payload}") - return self.client.request("PATCH", f"/contacts/{contact_id}", json=payload) + try: + return self.client.request("PATCH", f"/contacts/{contact_id}", json=payload) + except HTTPStatusError as e: + handle_404_error(e, contact_id, "Contact") @handle_exceptions def delete_contact(self, contact_id: str) -> None: @@ -100,6 +108,9 @@ def delete_contact(self, contact_id: str) -> None: None """ logger.info(f"Deleting contact with ID: {contact_id}") - response = self.client.request("DELETE", f"/contacts/{contact_id}") - logger.info(f"Successfully deleted contact with ID: {contact_id}") - return response + try: + response = self.client.request("DELETE", f"/contacts/{contact_id}") + logger.info(f"Successfully deleted contact with ID: {contact_id}") + return response + except HTTPStatusError as e: + handle_404_error(e, contact_id, "Contact") diff --git a/src/sdk/features/messages.py b/src/sdk/features/messages.py index 15077fb..426f6de 100644 --- a/src/sdk/features/messages.py +++ b/src/sdk/features/messages.py @@ -1,10 +1,12 @@ from typing import Dict, List +from httpx import HTTPStatusError + from ..client import ApiClient -from ..schemas.messages import CreateMessageRequest, Message, ListMessagesResponse -from src.common.validators import validate_request, validate_response -from src.common.exceptions import handle_exceptions -from src.common.logger import logger -from src.common.validators import verify_signature +from src.schemas.messages import CreateMessageRequest, Message, ListMessagesResponse +from src.core.validators import validate_request, validate_response +from src.core.exceptions import handle_exceptions, handle_404_error +from src.core.logger import logger +from src.core.security import verify_signature class Messages: @@ -69,7 +71,10 @@ def get_message(self, message_id: str) -> Message: Message: The retrieved message details. """ logger.info(f"Fetching message with ID: {message_id}") - return self.client.request("GET", f"/messages/{message_id}") + try: + return self.client.request("GET", f"/messages/{message_id}") + except HTTPStatusError as e: + handle_404_error(e, message_id, "Message") def validate_webhook_signature(self, raw_body: bytes, signature: str, secret: str): diff --git a/src/server/app.py b/src/server/app.py index 1065a33..cb99a2b 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -2,11 +2,12 @@ from fastapi import FastAPI, HTTPException, Header, Request from config import settings -from ..sdk.client import ApiClient -from .schemas import WebhookPayload -from ..sdk.features.messages import Messages -from src.common.validators import verify_signature -from src.common.logger import webhook_logger as logger +from src.sdk.client import ApiClient +from src.schemas.webhook import WebhookPayload +from src.sdk.features.messages import Messages +from src.core.security import verify_signature +from src.core.logger import webhook_logger as logger +from src.schemas.errors import UnauthorizedError, BadRequestError, ServerError # Initialize FastAPI app app = FastAPI() @@ -40,11 +41,19 @@ async def handle_webhook( print(f"Processed webhook payload: {payload.model_dump()}") return {"message": "Webhook processed successfully."} - + except ValueError as e: - logger.error(f"Signature validation failed: {str(e)}") - raise HTTPException(status_code=401, detail="Invalid signature.") - - except Exception as e: - logger.error(f"Error processing webhook: {str(e)}") - raise HTTPException(status_code=500, detail="Internal Server Error.") + raise HTTPException( + status_code=400, + detail=BadRequestError(error=str(e)).model_dump() + ) + except UnauthorizedError as e: + raise HTTPException( + status_code=401, + detail=e.message + ) + except Exception: + raise HTTPException( + status_code=500, + detail=ServerError(message="An unexpected error occurred").model_dump() + ) From 7b5e2f854da3ebbc1536c8f1b98c20322255258d Mon Sep 17 00:00:00 2001 From: Amirul Islam Date: Thu, 5 Dec 2024 18:33:18 +0100 Subject: [PATCH 2/5] Updated documentation, tests coverage, and validations --- README.md | 140 ++++++++---------- docs/sdk_usage.md | 2 +- docs/webhook_guide.md | 2 +- config.py => src/core/config.py | 2 +- src/sdk/client.py | 2 +- src/server/app.py | 2 +- tests/conftest.py | 3 +- .../sdk/test_contacts_integration.py | 2 +- .../sdk/test_messages_integration.py | 17 ++- tests/integration/server/test_webhook.py | 4 +- tests/unit/sdk/test_client.py | 4 +- tests/unit/sdk/test_contacts.py | 127 ++++++++-------- tests/unit/sdk/test_messages.py | 49 ++---- tests/unit/server/test_route.py | 24 +-- .../unit/server/test_signature_validation.py | 20 ++- 15 files changed, 185 insertions(+), 215 deletions(-) rename config.py => src/core/config.py (96%) diff --git a/README.md b/README.md index 7e351fb..5d9c9d1 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The **Messaging SDK** is a Python library that allows developers to interact wit 1. Clone the repository: ```bash - git clone https://github.com/your-repo/messaging-sdk.git + git clone https://github.com/shiningflash/messaging-sdk.git cd messaging-sdk ``` @@ -68,31 +68,19 @@ The **Messaging SDK** is a Python library that allows developers to interact wit ```bash cp .env.example .env ``` - - Edit `.env` and adjust the values: - ```env - BASE_URL=http://localhost:3000 - API_KEY=your-api-key - WEBHOOK_SECRET=mySecret - ``` + - Edit `.env` and adjust the values. -4. **Browse the API:** +4. Browse the API: - The repository includes an **OpenAPI Specification** file located at: `./docs/openapi.yaml`. This file describes the API's endpoints and can be viewed using tools like **SwaggerUI** or **Redocly**. - To explore the API visually, you can use Docker to run the provided tools: 1. Ensure Docker is installed on your machine. - 2. Start the servers: - ```bash - docker compose up - ``` - (If prompted, update the Docker images using `docker compose pull`). - + 2. Start the servers: `docker compose up`. 3. The following servers will be available: - **Swagger UI**: [http://localhost:8080](http://localhost:8080) - **Redocly**: [http://localhost:8090](http://localhost:8090) - **API Server**: [http://localhost:3000](http://localhost:3000) (uses a local database). - You can use either SwaggerUI or Redocly to browse and understand the API endpoints. - --- ## Usage Guide @@ -161,12 +149,63 @@ For detailed usage and examples, please refer to the **[User Guide](docs/webhook A detailed overview of the project structure, including descriptions of key files and directories. -## Root Directory - -``` +```plaintext +. ├── .github/ # GitHub workflows for CI/CD +│ └── workflows/ +│ └── ci.yml # Continuous Integration pipeline configuration ├── src/ # Source code directory +│ ├── core/ # Core modules for shared logic +│ │ ├── __init__.py # Core module initialization +│ │ ├── exceptions/ # Exception handling modules +│ │ │ ├── __init__.py # Consolidated exception imports +│ │ │ ├── api.py # API-specific exceptions +│ │ │ ├── decorators.py # Decorators for exception handling +│ │ │ └── resource.py # Resource-specific exceptions +│ │ ├── logger.py # Logging utilities +│ │ ├── requests.py # Request helpers for SDK +│ │ ├── retry.py # Retry logic for transient failures +│ │ ├── security.py # HMAC validation and signature generation +│ │ └── validators.py # Common validation logic +│ ├── schemas/ # Schema definitions for request/response +│ │ ├── __init__.py # Schemas initialization +│ │ ├── contacts.py # Contact-related schemas +│ │ ├── errors.py # Error schemas (aligned with OpenAPI specs) +│ │ ├── messages.py # Message-related schemas +│ │ └── webhook.py # Webhook payload schemas +│ ├── sdk/ # SDK-related functionalities +│ │ ├── __init__.py # SDK initialization +│ │ ├── client.py # API client for interacting with the server +│ │ └── features/ # API feature modules +│ │ ├── __init__.py # Features initialization +│ │ ├── contacts.py # Contacts-related SDK operations +│ │ └── messages.py # Messages-related SDK operations +│ ├── server/ # Webhook server implementation +│ │ ├── __init__.py # Server initialization +│ │ ├── app.py # Main FastAPI application +│ │ └── schemas.py # Schemas specific to the webhook server ├── tests/ # Testing files for unit, integration, and E2E +│ ├── __init__.py # Test package initialization +│ ├── conftest.py # Pytest fixtures and test setup +│ ├── e2e/ # End-to-End (E2E) tests +│ │ ├── __init__.py # E2E tests initialization +│ │ ├── test_contacts_e2e.py # E2E tests for contacts feature +│ │ └── test_messages_e2e.py # E2E tests for messages feature +│ ├── integration/ # Integration tests +│ │ ├── __init__.py # Integration tests initialization +│ │ ├── test_contacts_integration.py # Integration tests for contacts +│ │ ├── test_messages_integration.py # Integration tests for messages +│ │ └── test_webhook.py # Integration tests for webhook functionality +│ └── unit/ # Unit tests for SDK and server +│ ├── test_sdk/ # SDK-specific unit tests +│ │ ├── __init__.py # SDK unit tests initialization +│ │ ├── test_client.py # Unit tests for API client +│ │ ├── test_contacts.py # Unit tests for contacts module +│ │ └── test_messages.py # Unit tests for messages module +│ └── test_server/ # Server-specific unit tests +│ ├── __init__.py # Server unit tests initialization +│ ├── test_route.py # Unit tests for API routes +│ └── test_signature_validation.py # Unit tests for signature validation ├── venv/ # Python virtual environment (not versioned) ├── .env.example # Example environment variables ├── config.py # Global configuration file for SDK and server @@ -176,71 +215,10 @@ A detailed overview of the project structure, including descriptions of key file ├── requirements.txt # Locked Python dependencies ├── README.md # Project documentation and usage guide ├── docs/ # Additional documentation +│ ├── openapi.yaml # OpenAPI docs │ ├── sdk_usage.md # Comprehensive SDK usage documentation │ └── webhook_guide.md # Webhook-specific documentation -``` - ---- - -#### `/src` Directory -The main application source code is organized as follows: - -``` -/src -├── sdk/ # SDK-related functionalities -│ ├── __init__.py # SDK initialization -│ ├── client.py # API client for interacting with the server -│ ├── features/ # API feature modules -│ │ ├── __init__.py # Features initialization -│ │ ├── contacts.py # Contacts-related SDK operations -│ │ └── messages.py # Messages-related SDK operations -│ ├── schemas/ # Schema definitions for request/response -│ │ ├── __init__.py # Schemas initialization -│ │ ├── contacts.py # Contact-related schemas -│ │ └── messages.py # Message-related schemas -│ └── utils/ # Utility modules -│ ├── __init__.py # Utilities initialization -│ ├── exceptions.py # Custom exceptions for error handling -│ ├── logger.py # Logging utilities -│ ├── requests.py # Request helpers for SDK -│ ├── retry.py # Retry logic for transient failures -│ └── validators.py # Validators for request/response data -├── server/ # Webhook server implementation -│ ├── __init__.py # Server initialization -│ ├── app.py # Main FastAPI application -│ └── schemas.py # Schemas specific to the webhook server -``` - ---- - -#### `/tests` Directory - -The testing framework is organized as follows: - -``` -/tests -├── __init__.py # Test package initialization -├── conftest.py # Pytest fixtures and test setup -├── e2e/ # End-to-End (E2E) tests -│ ├── __init__.py # E2E tests initialization -│ ├── test_contacts_e2e.py # E2E tests for contacts feature -│ └── test_messages_e2e.py # E2E tests for messages feature -├── integration/ # Integration tests -│ ├── __init__.py # Integration tests initialization -│ ├── test_contacts_integration.py # Integration tests for contacts -│ ├── test_end_to_end_workflows.py # Comprehensive workflow tests -│ ├── test_messages_integration.py # Integration tests for messages -│ └── test_webhook.py # Integration tests for webhook functionality -└── unit/ # Unit tests for SDK and server - ├── test_sdk/ # SDK-specific unit tests - │ ├── __init__.py # SDK unit tests initialization - │ ├── test_client.py # Unit tests for API client - │ ├── test_contacts.py # Unit tests for contacts module - │ └── test_messages.py # Unit tests for messages module - └── test_server/ # Server-specific unit tests - ├── test_route.py # Unit tests for API routes - └── test_signature_validation.py # Unit tests for signature validation ``` --- diff --git a/docs/sdk_usage.md b/docs/sdk_usage.md index 5dd6e47..fc60783 100644 --- a/docs/sdk_usage.md +++ b/docs/sdk_usage.md @@ -171,7 +171,7 @@ The SDK includes comprehensive logging for debugging and auditing. Logs are cate Example of enabling logger in your application: ```python -from src.common.logger import logger +from src.core.logger import logger logger.info("Application started.") ``` diff --git a/docs/webhook_guide.md b/docs/webhook_guide.md index 7e5e51b..4ddcaf6 100644 --- a/docs/webhook_guide.md +++ b/docs/webhook_guide.md @@ -74,7 +74,7 @@ The SDK provides a `verify_signature` utility to validate the HMAC signature. He Example Code: ```python -from src.common.validators import verify_signature +from src.core.security import verify_signature try: verify_signature(message=raw_payload, signature=auth_header, secret=WEBHOOK_SECRET) diff --git a/config.py b/src/core/config.py similarity index 96% rename from config.py rename to src/core/config.py index 3d5170f..2f854d0 100644 --- a/config.py +++ b/src/core/config.py @@ -1,6 +1,6 @@ from pydantic import Field, field_validator, ConfigDict from pydantic_settings import BaseSettings -from src.common.logger import logger +from src.core.logger import logger class Settings(BaseSettings): diff --git a/src/sdk/client.py b/src/sdk/client.py index 275c44f..a09f5fc 100644 --- a/src/sdk/client.py +++ b/src/sdk/client.py @@ -1,6 +1,6 @@ import requests from typing import Any -from config import settings +from src.core.config import settings from src.core.logger import logger from src.core.requests import handle_request_errors from src.core.exceptions import UnauthorizedError, NotFoundError, ServerError, ApiError, TransientError diff --git a/src/server/app.py b/src/server/app.py index cb99a2b..c605197 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -1,7 +1,7 @@ import json from fastapi import FastAPI, HTTPException, Header, Request -from config import settings +from src.core.config import settings from src.sdk.client import ApiClient from src.schemas.webhook import WebhookPayload from src.sdk.features.messages import Messages diff --git a/tests/conftest.py b/tests/conftest.py index ad455d7..34ae311 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,8 @@ import pytest -from unittest.mock import MagicMock from src.sdk.client import ApiClient from src.sdk.features.contacts import Contacts from src.sdk.features.messages import Messages -from unittest.mock import patch, call +from unittest.mock import patch @pytest.fixture diff --git a/tests/integration/sdk/test_contacts_integration.py b/tests/integration/sdk/test_contacts_integration.py index 6fec2a6..327661f 100644 --- a/tests/integration/sdk/test_contacts_integration.py +++ b/tests/integration/sdk/test_contacts_integration.py @@ -1,5 +1,5 @@ import pytest -from src.common.exceptions import NotFoundError +from src.core.exceptions import NotFoundError def test_create_contact_success(contacts, mock_api_client): diff --git a/tests/integration/sdk/test_messages_integration.py b/tests/integration/sdk/test_messages_integration.py index 9d6ba51..8e713ee 100644 --- a/tests/integration/sdk/test_messages_integration.py +++ b/tests/integration/sdk/test_messages_integration.py @@ -1,5 +1,5 @@ import pytest -from src.common.exceptions import ApiError, NotFoundError, UnauthorizedError +from src.core.exceptions import ApiError, UnauthorizedError, MessageNotFoundError def test_send_message_success(messages, mock_api_client): """ @@ -112,13 +112,16 @@ def test_get_message_success(messages, mock_api_client): def test_get_message_not_found(messages, mock_api_client): - """ - Test retrieving a message that does not exist. - """ - mock_api_client.request.side_effect = NotFoundError("Message not found.") + """Test retrieving a message that does not exist.""" + # Mock API 404 error response with MessageNotFoundError + mock_api_client.request.side_effect = MessageNotFoundError( + id="non-existent", + message="Message not found." + ) - with pytest.raises(NotFoundError, match="Message not found."): - messages.get_message("msg999") + # Assertions + with pytest.raises(MessageNotFoundError, match="Message not found."): + messages.get_message(message_id="non-existent") def test_get_message_unauthorized(messages, mock_api_client): diff --git a/tests/integration/server/test_webhook.py b/tests/integration/server/test_webhook.py index f90d8cb..540003a 100644 --- a/tests/integration/server/test_webhook.py +++ b/tests/integration/server/test_webhook.py @@ -1,8 +1,8 @@ import json from fastapi.testclient import TestClient -from config import settings +from src.core.config import settings from src.server.app import app -from src.common.validators import generate_signature +from src.core.security import generate_signature client = TestClient(app) diff --git a/tests/unit/sdk/test_client.py b/tests/unit/sdk/test_client.py index bd2f9f6..2bbd44f 100644 --- a/tests/unit/sdk/test_client.py +++ b/tests/unit/sdk/test_client.py @@ -1,8 +1,8 @@ import pytest from unittest.mock import patch, MagicMock from src.sdk.client import ApiClient -from src.common.exceptions import UnauthorizedError, NotFoundError, ServerError, ApiError -from config import settings +from src.core.exceptions import UnauthorizedError, NotFoundError, ServerError, ApiError +from src.core.config import settings @pytest.fixture diff --git a/tests/unit/sdk/test_contacts.py b/tests/unit/sdk/test_contacts.py index 41c51f8..9c13172 100644 --- a/tests/unit/sdk/test_contacts.py +++ b/tests/unit/sdk/test_contacts.py @@ -1,126 +1,131 @@ import pytest -from unittest.mock import MagicMock, patch -from src.sdk.features.contacts import Contacts -from src.sdk.client import ApiClient -from src.common.exceptions import UnauthorizedError, NotFoundError, ApiError, PayloadValidationError -from src.sdk.schemas.contacts import CreateContactRequest +from src.core.exceptions import ApiError, ContactNotFoundError -@pytest.fixture -def api_client(): - """Fixture to initialize a mock ApiClient.""" - return MagicMock(spec=ApiClient) +def test_create_contact_success(contacts, mock_api_client): + """Test creating a contact successfully.""" + # Mock API response + mock_api_client.request.return_value = {"id": "123", "name": "John Doe", "phone": "+123456789"} - -@pytest.fixture -def contacts(api_client): - """Fixture to initialize the Contacts class.""" - return Contacts(client=api_client) - - -def test_create_contact_success(contacts, api_client): - """Test successfully creating a contact.""" - # Mock response - api_client.request.return_value = {"id": "123", "name": "John Doe", "phone": "+123456789"} + # Input payload + payload = {"name": "John Doe", "phone": "+123456789"} # Call method - payload = {"name": "John Doe", "phone": "+123456789"} response = contacts.create_contact(payload=payload) # Assertions - api_client.request.assert_called_once_with("POST", "/contacts", json=payload) + mock_api_client.request.assert_called_once_with("POST", "/contacts", json=payload) assert response["id"] == "123" assert response["name"] == "John Doe" + assert response["phone"] == "+123456789" def test_create_contact_validation_error(contacts): - """Test validation error when payload is invalid.""" - payload = {"name": "John Doe"} # Missing 'phone' + """Test validation error when creating a contact with invalid data.""" + # Missing 'phone' in payload + payload = {"name": "John Doe"} + + # Assertions with pytest.raises(ValueError, match="Invalid payload"): contacts.create_contact(payload=payload) -def test_create_contact_api_error(contacts, api_client): +def test_create_contact_api_error(contacts, mock_api_client): """Test API error during contact creation.""" # Mock API error - api_client.request.side_effect = ApiError("Unhandled API Error") + mock_api_client.request.side_effect = ApiError("Unhandled API Error") + # Input payload payload = {"name": "John Doe", "phone": "+123456789"} + + # Assertions with pytest.raises(ApiError, match="Unhandled API Error"): contacts.create_contact(payload=payload) -def test_list_contacts_success(contacts, api_client): - """Test successfully listing contacts.""" - # Mock response - mock_response = { +def test_list_contacts_success(contacts, mock_api_client): + """Test listing contacts successfully with pagination.""" + # Mock API response + mock_api_client.request.return_value = { "contactsList": [{"id": "123", "name": "John Doe", "phone": "+123456789"}], "pageNumber": 1, "pageSize": 10, } - api_client.request.return_value = mock_response # Call method response = contacts.list_contacts(page=1, max=10) # Assertions - api_client.request.assert_called_once_with("GET", "/contacts", params={"pageIndex": 1, "max": 10}) + mock_api_client.request.assert_called_once_with("GET", "/contacts", params={"pageIndex": 1, "max": 10}) + assert response["pageNumber"] == 1 assert len(response["contactsList"]) == 1 assert response["contactsList"][0]["id"] == "123" + assert response["contactsList"][0]["name"] == "John Doe" -def test_get_contact_success(contacts, api_client): - """Test successfully retrieving a specific contact.""" - # Mock response - mock_response = {"id": "123", "name": "John Doe", "phone": "+123456789"} - api_client.request.return_value = mock_response +def test_get_contact_success(contacts, mock_api_client): + """Test retrieving a contact successfully by ID.""" + # Mock API response + mock_api_client.request.return_value = {"id": "123", "name": "John Doe", "phone": "+123456789"} # Call method response = contacts.get_contact(contact_id="123") # Assertions - api_client.request.assert_called_once_with("GET", "/contacts/123") + mock_api_client.request.assert_called_once_with("GET", "/contacts/123") assert response["id"] == "123" assert response["name"] == "John Doe" + assert response["phone"] == "+123456789" -def test_get_contact_not_found(contacts, api_client): - """Test 404 Not Found error when retrieving a contact.""" - # Mock 404 error - api_client.request.side_effect = NotFoundError("Resource not found.") - - with pytest.raises(NotFoundError, match="Resource not found."): - contacts.get_contact(contact_id="non-existent") +def test_update_contact_success(contacts, mock_api_client): + """Test updating a contact successfully.""" + # Mock API response + mock_api_client.request.return_value = {"id": "123", "name": "Jane Doe", "phone": "+987654321"} - -def test_update_contact_success(contacts, api_client): - """Test successfully updating a contact.""" - # Mock response - mock_response = {"id": "123", "name": "Jane Doe", "phone": "+987654321"} - api_client.request.return_value = mock_response + # Input payload + payload = {"name": "Jane Doe", "phone": "+987654321"} # Call method - payload = {"name": "Jane Doe", "phone": "+987654321"} response = contacts.update_contact(contact_id="123", payload=payload) # Assertions - api_client.request.assert_called_once_with("PATCH", "/contacts/123", json=payload) + mock_api_client.request.assert_called_once_with("PATCH", "/contacts/123", json=payload) + assert response["id"] == "123" assert response["name"] == "Jane Doe" + assert response["phone"] == "+987654321" -def test_delete_contact_success(contacts, api_client): - """Test successfully deleting a contact.""" +def test_delete_contact_success(contacts, mock_api_client): + """Test deleting a contact successfully.""" # Call method contacts.delete_contact(contact_id="123") # Assertions - api_client.request.assert_called_once_with("DELETE", "/contacts/123") + mock_api_client.request.assert_called_once_with("DELETE", "/contacts/123") + + +def test_get_contact_not_found(contacts, mock_api_client): + """Test retrieving a contact that does not exist.""" + # Mock API 404 error response with ContactNotFoundError + mock_api_client.request.side_effect = ContactNotFoundError( + id="non-existent", + message="Contact not found." + ) + + # Assertions + with pytest.raises(ContactNotFoundError, match="Contact not found."): + contacts.get_contact(contact_id="non-existent") -def test_delete_contact_not_found(contacts, api_client): - """Test deleting a non-existent contact.""" - # Mock 404 error - api_client.request.side_effect = NotFoundError("Resource not found.") +def test_delete_contact_not_found(contacts, mock_api_client): + """Test deleting a contact that does not exist.""" + # Mock API 404 error response with ContactNotFoundError + mock_api_client.request.side_effect = ContactNotFoundError( + id="non-existent", + message="Contact not found." + ) - with pytest.raises(NotFoundError, match="Resource not found."): + # Assertions + with pytest.raises(ContactNotFoundError, match="Contact not found."): contacts.delete_contact(contact_id="non-existent") diff --git a/tests/unit/sdk/test_messages.py b/tests/unit/sdk/test_messages.py index 8e987af..607c674 100644 --- a/tests/unit/sdk/test_messages.py +++ b/tests/unit/sdk/test_messages.py @@ -1,27 +1,11 @@ import pytest -from unittest.mock import MagicMock, patch -from src.sdk.features.messages import Messages -from src.sdk.client import ApiClient -from src.common.exceptions import UnauthorizedError, NotFoundError, ApiError, PayloadValidationError -from src.sdk.schemas.messages import CreateMessageRequest +from src.core.exceptions import ApiError -@pytest.fixture -def api_client(): - """Fixture to initialize a mock ApiClient.""" - return MagicMock(spec=ApiClient) - - -@pytest.fixture -def messages(api_client): - """Fixture to initialize the Messages class.""" - return Messages(client=api_client) - - -def test_send_message_success(messages, api_client): +def test_send_message_success(messages, mock_api_client): """Test successfully sending a message.""" # Mock response - api_client.request.return_value = { + mock_api_client.request.return_value = { "id": "msg123", "from": "+123456789", "to": "+987654321", @@ -35,7 +19,7 @@ def test_send_message_success(messages, api_client): response = messages.send_message(payload=payload) # Assertions - api_client.request.assert_called_once_with("POST", "/messages", json=payload) + mock_api_client.request.assert_called_once_with("POST", "/messages", json=payload) assert response["id"] == "msg123" assert response["status"] == "queued" assert response["content"] == "Hello, World!" @@ -48,17 +32,17 @@ def test_send_message_validation_error(messages): messages.send_message(payload=payload) -def test_send_message_api_error(messages, api_client): +def test_send_message_api_error(messages, mock_api_client): """Test API error during message sending.""" # Mock API error - api_client.request.side_effect = ApiError("Unhandled API Error") + mock_api_client.request.side_effect = ApiError("Unhandled API Error") payload = {"to": "+987654321", "content": "Hello, World!", "sender": "+123456789"} with pytest.raises(ApiError, match="Unhandled API Error"): messages.send_message(payload=payload) -def test_list_messages_success(messages, api_client): +def test_list_messages_success(messages, mock_api_client): """Test successfully listing messages.""" # Mock response mock_response = { @@ -75,18 +59,18 @@ def test_list_messages_success(messages, api_client): "page": 1, "quantityPerPage": 10 } - api_client.request.return_value = mock_response + mock_api_client.request.return_value = mock_response # Call method response = messages.list_messages(page=1, limit=10) # Assertions - api_client.request.assert_called_once_with("GET", "/messages", params={"page": 1, "limit": 10}) + mock_api_client.request.assert_called_once_with("GET", "/messages", params={"page": 1, "limit": 10}) assert len(response["messages"]) == 1 assert response["messages"][0]["id"] == "msg123" -def test_get_message_success(messages, api_client): +def test_get_message_success(messages, mock_api_client): """Test successfully retrieving a specific message.""" # Mock response mock_response = { @@ -97,21 +81,12 @@ def test_get_message_success(messages, api_client): "status": "queued", "createdAt": "2024-11-28T10:00:00Z" } - api_client.request.return_value = mock_response + mock_api_client.request.return_value = mock_response # Call method response = messages.get_message(message_id="msg123") # Assertions - api_client.request.assert_called_once_with("GET", "/messages/msg123") + mock_api_client.request.assert_called_once_with("GET", "/messages/msg123") assert response["id"] == "msg123" assert response["content"] == "Hello, World!" - - -def test_get_message_not_found(messages, api_client): - """Test 404 Not Found error when retrieving a message.""" - # Mock 404 error - api_client.request.side_effect = NotFoundError("Resource not found.") - - with pytest.raises(NotFoundError, match="Resource not found."): - messages.get_message(message_id="non-existent") diff --git a/tests/unit/server/test_route.py b/tests/unit/server/test_route.py index bcca56b..30b0137 100644 --- a/tests/unit/server/test_route.py +++ b/tests/unit/server/test_route.py @@ -2,13 +2,11 @@ from fastapi.testclient import TestClient from src.server.app import app -from config import settings - -from src.common.validators import generate_signature +from src.core.config import settings +from src.core.security import generate_signature client = TestClient(app) - def test_webhook_valid_request(): payload = { "id": "msg123", @@ -30,21 +28,22 @@ def test_webhook_valid_request(): assert response.status_code == 200 assert response.json() == {"message": "Webhook processed successfully."} - def test_webhook_invalid_signature(): payload = { "id": "msg123", "status": "delivered", "deliveredAt": "2024-12-01T12:00:00Z", } + # Serialize payload to JSON with stable formatting + serialized_payload = json.dumps(payload, separators=(",", ":")).encode("utf-8") + response = client.post( "/webhooks", - json=payload, # Send payload as JSON + content=serialized_payload, # Send raw serialized JSON payload headers={"Authorization": "Bearer invalid-signature"} ) assert response.status_code == 401 - assert response.json() == {"detail": "Invalid signature."} - + assert response.json() == {"detail": "Unauthorized: Signature validation failed."} def test_webhook_invalid_payload(): invalid_payload = { @@ -53,11 +52,16 @@ def test_webhook_invalid_payload(): } # Serialize payload for signature generation serialized_payload = json.dumps(invalid_payload, separators=(",", ":")).encode("utf-8") + + # Generate a valid signature for the serialized payload signature = generate_signature(invalid_payload, settings.WEBHOOK_SECRET) response = client.post( "/webhooks", - json=invalid_payload, # Use json= to send as JSON + content=serialized_payload, # Send raw serialized JSON payload headers={"Authorization": f"Bearer {signature}"} ) - assert response.status_code == 422 # Ensure validation catches the invalid type + assert response.status_code == 422 + + assert "status" in response.json()["detail"][0]["loc"] + assert response.json()["detail"][0]["msg"] == "Input should be 'queued', 'delivered' or 'failed'" \ No newline at end of file diff --git a/tests/unit/server/test_signature_validation.py b/tests/unit/server/test_signature_validation.py index 41242c3..c60efa7 100644 --- a/tests/unit/server/test_signature_validation.py +++ b/tests/unit/server/test_signature_validation.py @@ -3,9 +3,10 @@ import hashlib import json -from config import settings -from src.common.validators import verify_signature -from src.common.validators import generate_signature +from src.core.config import settings +from src.core.security import verify_signature +from src.core.security import generate_signature +from src.schemas.errors import UnauthorizedError def test_valid_signature(): @@ -16,12 +17,17 @@ def test_valid_signature(): def test_invalid_signature(): payload = {"id": "msg123", "status": "delivered"} + serialized_payload = json.dumps(payload, separators=(",", ":")).encode("utf-8") invalid_signature = "invalidsignature" - with pytest.raises(ValueError, match="Signature validation failed."): - verify_signature(payload, invalid_signature, settings.WEBHOOK_SECRET) + + with pytest.raises(UnauthorizedError, match="Unauthorized: Signature validation failed."): + verify_signature(serialized_payload, invalid_signature, settings.WEBHOOK_SECRET) def test_empty_signature(): payload = {"id": "msg123", "status": "delivered"} + serialized_payload = json.dumps(payload, separators=(",", ":")).encode("utf-8") empty_signature = "" - with pytest.raises(ValueError, match="Signature validation failed."): - verify_signature(payload, empty_signature, settings.WEBHOOK_SECRET) + + with pytest.raises(UnauthorizedError, match="Unauthorized: Signature validation failed."): + verify_signature(serialized_payload, empty_signature, settings.WEBHOOK_SECRET) + From f979bbd50f655b989b5f6d9fc132ccb6fa8d17e7 Mon Sep 17 00:00:00 2001 From: Amirul Islam Date: Thu, 5 Dec 2024 18:43:22 +0100 Subject: [PATCH 3/5] Updated documentation --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5d9c9d1..bb5b7d4 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,9 @@ The **Messaging SDK** is a Python library that allows developers to interact wit print(response) ``` -#### Comprehensive User Guide for **[SDK Usage](docs/sdk_usage.md)** -For detailed usage and examples, please refer to the **[User Guide](docs/sdk_usage.md)** 👈. +#### Comprehensive User Guide for SDK Usage + +[![SDK Usage Documentation](https://img.shields.io/badge/Docs-SDK%20Usage%20Guide-blue?style=for-the-badge)](docs/sdk_usage.md) ### Webhook Setup 1. Run the webhook server: @@ -120,8 +121,9 @@ For detailed usage and examples, please refer to the **[User Guide](docs/sdk_usa 2. Configure the API to send events to your webhook endpoint (e.g., `http://localhost:3010/webhooks`). -#### Comprehensive User Guide for **[Webhook](docs/webhook_guide.md)** -For detailed usage and examples, please refer to the **[User Guide](docs/webhook_guide.md)** 👈. +#### Comprehensive User Guide for Webhook + +[![Webhook Documentation](https://img.shields.io/badge/Docs-Webhook%20Guide-blue?style=for-the-badge)](docs/webhook_guide.md) --- From 9b24809acfa955ff2a5ab92efc6f2c6ad06f6e61 Mon Sep 17 00:00:00 2001 From: Amirul Islam Date: Thu, 5 Dec 2024 18:44:46 +0100 Subject: [PATCH 4/5] Refactor features --- src/sdk/features/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sdk/features/messages.py b/src/sdk/features/messages.py index 426f6de..c38286a 100644 --- a/src/sdk/features/messages.py +++ b/src/sdk/features/messages.py @@ -1,4 +1,4 @@ -from typing import Dict, List +from typing import Dict from httpx import HTTPStatusError from ..client import ApiClient From e689893c9c8c2dcf62a157461b3360002fdb4f15 Mon Sep 17 00:00:00 2001 From: Amirul Islam Date: Thu, 5 Dec 2024 18:49:37 +0100 Subject: [PATCH 5/5] Updated documentation --- README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bb5b7d4..eb83843 100644 --- a/README.md +++ b/README.md @@ -64,28 +64,22 @@ The **Messaging SDK** is a Python library that allows developers to interact wit ``` 3. Configure environment variables: - - Copy `.env.example` to `.env`: - ```bash - cp .env.example .env - ``` + - Copy `.env.example` to `.env`: `cp .env.example .env` - Edit `.env` and adjust the values. 4. Browse the API: - - The repository includes an **OpenAPI Specification** file located at: `./docs/openapi.yaml`. This file describes the API's endpoints and can be viewed using tools like **SwaggerUI** or **Redocly**. - + - The repository includes an **OpenAPI Specification** file located at: `./docs/openapi.yaml`. - To explore the API visually, you can use Docker to run the provided tools: 1. Ensure Docker is installed on your machine. 2. Start the servers: `docker compose up`. - 3. The following servers will be available: - - **Swagger UI**: [http://localhost:8080](http://localhost:8080) - - **Redocly**: [http://localhost:8090](http://localhost:8090) - - **API Server**: [http://localhost:3000](http://localhost:3000) (uses a local database). + 3. The following servers will be available: **Swagger UI**: [http://localhost:8080](http://localhost:8080), **Redocly**: [http://localhost:8090](http://localhost:8090), **API Server**: [http://localhost:3000](http://localhost:3000) (uses a local database). --- ## Usage Guide ### SDK Usage + 1. Initialize the SDK: ```python from src.sdk.client import ApiClient @@ -114,6 +108,7 @@ The **Messaging SDK** is a Python library that allows developers to interact wit [![SDK Usage Documentation](https://img.shields.io/badge/Docs-SDK%20Usage%20Guide-blue?style=for-the-badge)](docs/sdk_usage.md) ### Webhook Setup + 1. Run the webhook server: ```bash uvicorn src.server.app:app --reload --port 3010 @@ -164,6 +159,7 @@ A detailed overview of the project structure, including descriptions of key file │ │ │ ├── api.py # API-specific exceptions │ │ │ ├── decorators.py # Decorators for exception handling │ │ │ └── resource.py # Resource-specific exceptions +│ │ ├── config.py # Global configuration file for SDK and server │ │ ├── logger.py # Logging utilities │ │ ├── requests.py # Request helpers for SDK │ │ ├── retry.py # Retry logic for transient failures @@ -210,7 +206,6 @@ A detailed overview of the project structure, including descriptions of key file │ └── test_signature_validation.py # Unit tests for signature validation ├── venv/ # Python virtual environment (not versioned) ├── .env.example # Example environment variables -├── config.py # Global configuration file for SDK and server ├── docker-compose.yml # Docker Compose configuration ├── pytest.ini # Pytest configuration ├── requirements.in # Base Python dependencies