auth service completed and tested

This commit is contained in:
berkay 2025-01-14 19:16:24 +03:00
parent 08b1815156
commit 486fadbfb3
33 changed files with 1325 additions and 248 deletions

View File

@ -0,0 +1,37 @@
"""
FastAPI Application Entry Point
This module initializes and configures the FastAPI application with:
- CORS middleware for cross-origin requests
- Request timing middleware for performance monitoring
- Custom exception handlers for consistent error responses
- Prometheus instrumentation for metrics
- API routers for endpoint organization
"""
import uvicorn
import routers
from create_file import create_app
from prometheus_fastapi_instrumentator import Instrumentator
from app_handler import setup_middleware, get_uvicorn_config
print("Loading app.py module...")
# Initialize FastAPI application
app = create_app(routers=routers)
# Setup Prometheus metrics
Instrumentator().instrument(app=app).expose(app=app)
# Configure middleware and exception handlers
setup_middleware(app)
if __name__ == "__main__":
print("Starting server from __main__...")
# Run the application with Uvicorn
uvicorn_config = get_uvicorn_config()
print(f"Using config: {uvicorn_config}")
uvicorn.Server(uvicorn.Config(**uvicorn_config)).run()

View File

@ -0,0 +1,121 @@
"""
FastAPI Application Handler Module
This module contains all the handler functions for configuring and setting up the FastAPI application:
- CORS middleware configuration
- Exception handlers setup
- Uvicorn server configuration
"""
from typing import Dict, Any
from fastapi.middleware.cors import CORSMiddleware
from fastapi import FastAPI, Request, HTTPException, status
from fastapi.responses import JSONResponse
from ErrorHandlers.bases import (
BaseErrorModelClass,
StatusesModelClass,
LanguageModelClass,
)
from ErrorHandlers import statuses
from middleware.auth_middleware import MiddlewareModule
def setup_cors_middleware(app: FastAPI) -> None:
"""
Configure CORS middleware for the application.
Args:
app: FastAPI application instance
"""
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
"""
Handle HTTP exceptions and return formatted error responses.
Args:
request: FastAPI request object
exc: HTTP exception instance
Returns:
JSONResponse: Formatted error response
"""
error_code = getattr(exc, "error_code", None)
if error_code:
status_code = StatusesModelClass.retrieve_error_by_code(error_code)
error_message = LanguageModelClass.retrieve_error_by_code(
error_code, request.headers.get("accept-language", "en")
)
else:
status_code = exc.status_code
error_message = str(exc.detail)
return JSONResponse(
status_code=status_code,
content={"detail": error_message, "error_code": error_code},
)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""
Handle generic exceptions and return formatted error responses.
Args:
request: FastAPI request object
exc: Exception instance
Returns:
JSONResponse: Formatted error response
"""
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error", "error_code": "INTERNAL_ERROR"},
)
def setup_exception_handlers(app: FastAPI) -> None:
"""
Configure custom exception handlers for the application.
Args:
app: FastAPI application instance
"""
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(Exception, generic_exception_handler)
def setup_middleware(app: FastAPI) -> None:
"""
Configure all middleware for the application.
Args:
app: FastAPI application instance
"""
setup_cors_middleware(app)
app.add_middleware(MiddlewareModule.RequestTimingMiddleware)
setup_exception_handlers(app)
def get_uvicorn_config() -> Dict[str, Any]:
"""
Get Uvicorn server configuration.
Returns:
Dict[str, Any]: Uvicorn configuration dictionary
"""
return {
"app": "app:app",
"host": "0.0.0.0",
"port": 41575,
"log_level": "info",
"reload": True,
}

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,21 @@
"""
Base FastAPI application configuration.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
def create_app() -> FastAPI:
app = FastAPI(title="API Service")
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
return app

View File

@ -0,0 +1,152 @@
"""
FastAPI Application Factory Module
This module provides functionality to create and configure a FastAPI application with:
- Custom OpenAPI schema configuration
- Security scheme configuration for Bearer authentication
- Automatic router registration
- Response class configuration
- Security requirements for protected endpoints
"""
from types import ModuleType
from typing import Any, Dict, List, Optional, Union
from fastapi import FastAPI, APIRouter
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.openapi.utils import get_openapi
from fastapi.routing import APIRoute
from AllConfigs.main import MainConfig as Config
from middleware.auth_middleware import MiddlewareModule
def setup_security_schema() -> Dict[str, Any]:
"""
Configure security schema for the OpenAPI documentation.
Returns:
Dict[str, Any]: Security schema configuration
"""
return {
"Bearer": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Enter the token",
}
}
def configure_route_security(
path: str,
method: str,
schema: Dict[str, Any],
protected_paths: List[str]
) -> None:
"""
Configure security requirements for a specific route.
Args:
path: Route path
method: HTTP method
schema: OpenAPI schema to modify
protected_paths: List of paths that require authentication
"""
if path in protected_paths:
if "paths" not in schema:
schema["paths"] = {}
if path not in schema["paths"]:
schema["paths"][path] = {}
if method not in schema["paths"][path]:
schema["paths"][path][method] = {}
schema["paths"][path][method]["security"] = [{"Bearer": []}]
def get_routers(routers_module: ModuleType) -> List[APIRouter]:
"""
Extract all routers from the routers module.
Args:
routers_module: Module containing router definitions
Returns:
List[APIRouter]: List of router instances
"""
routers = []
for attr_name in dir(routers_module):
attr = getattr(routers_module, attr_name)
if isinstance(attr, APIRouter):
routers.append(attr)
return routers
def create_app(routers: ModuleType) -> FastAPI:
"""
Create and configure a FastAPI application.
Args:
routers: Module containing router definitions
Returns:
FastAPI: Configured FastAPI application instance
"""
# Initialize FastAPI app
app = FastAPI(
title=Config.TITLE,
description=Config.DESCRIPTION,
default_response_class=JSONResponse,
)
# Add home route that redirects to API documentation
@app.get("/", include_in_schema=False, summary=str(Config.DESCRIPTION))
async def home() -> RedirectResponse:
"""Redirect root path to API documentation."""
return RedirectResponse(url="/docs")
# Get all routers
router_instances = get_routers(routers)
# Find protected paths
protected_paths = []
for router in router_instances:
for route in router.routes:
if isinstance(route, APIRoute):
# Check if the route has auth_required decorator
if any(d.__name__ == 'auth_required' for d in route.dependencies):
protected_paths.append(route.path)
# Include routers
for router in router_instances:
app.include_router(router)
# Configure custom OpenAPI schema
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=Config.TITLE,
version="1.0.0",
description=Config.DESCRIPTION,
routes=app.routes,
)
# Add security schemes
openapi_schema["components"] = {"securitySchemes": setup_security_schema()}
# Configure security for each route
for route in app.routes:
if isinstance(route, APIRoute):
configure_route_security(
route.path,
route.methods.pop().lower(),
openapi_schema,
protected_paths
)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
return app

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,179 @@
"""
Authentication and Authorization middleware for FastAPI applications.
This module provides authentication decorator for protecting endpoints
and a middleware for request timing measurements.
"""
from time import perf_counter
from typing import Callable, Optional, Dict, Any, Tuple
from functools import wraps
from fastapi import HTTPException, Request, Response, status
from starlette.middleware.base import BaseHTTPMiddleware
from AllConfigs.Token.config import Auth
from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi
class MiddlewareModule:
"""
Module containing authentication and middleware functionality.
This class provides:
- Token extraction and validation
- Authentication decorator for endpoints
- Request timing middleware
"""
@staticmethod
def get_access_token(request: Request) -> Tuple[str, str]:
"""
Extract access token from request headers.
Args:
request: FastAPI request object
Returns:
Tuple[str, str]: A tuple containing (scheme, token)
Raises:
HTTPExceptionApi: If token is missing or malformed
"""
auth_header = request.headers.get(Auth.ACCESS_TOKEN_TAG)
if not auth_header:
raise HTTPExceptionApi(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No authorization header",
)
try:
scheme, token = auth_header.split()
if scheme.lower() != "bearer":
raise HTTPExceptionApi(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
)
return scheme, token
except ValueError:
raise HTTPExceptionApi(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token format"
)
@staticmethod
async def validate_token(token: str) -> Dict[str, Any]:
"""
Validate the authentication token.
Args:
token: JWT token to validate
Returns:
Dict[str, Any]: User data extracted from token
Raises:
HTTPExceptionApi: If token is invalid
"""
try:
# TODO: Implement your token validation logic
# Example:
# return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
return {"user_id": "test", "role": "user"} # Placeholder
except Exception as e:
raise HTTPExceptionApi(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token validation failed: {str(e)}",
)
@classmethod
def auth_required(cls, func: Callable) -> Callable:
"""
Decorator for protecting FastAPI endpoints with authentication.
Usage:
@router.get("/protected")
@MiddlewareModule.auth_required
async def protected_endpoint(request: Request):
user = request.state.user # Access authenticated user data
return {"message": "Protected content"}
@router.get("/public") # No decorator = public endpoint
async def public_endpoint():
return {"message": "Public content"}
Args:
func: The FastAPI route handler function to protect
Returns:
Callable: Wrapped function that checks authentication before execution
"""
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
try:
# Get token from header
_, token = cls.get_access_token(request)
# Validate token and get user data
token_data = await cls.validate_token(token)
# Add user data to request state for use in endpoint
request.state.user = token_data
# Call the original endpoint function
return await func(request, *args, **kwargs)
except HTTPExceptionApi:
raise
except Exception as e:
raise HTTPExceptionApi(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Authentication failed: {str(e)}",
)
return wrapper
class RequestTimingMiddleware(BaseHTTPMiddleware):
"""
Middleware for measuring and logging request timing.
Only handles timing, no authentication.
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""
Process each request through the middleware.
Args:
request: FastAPI request object
call_next: Next middleware in the chain
Returns:
Response: Processed response with timing headers
"""
start_time = perf_counter()
# Process the request
response = await call_next(request)
# Add timing information to response headers
self._add_timing_headers(response, start_time)
return response
@staticmethod
def _add_timing_headers(response: Response, start_time: float) -> None:
"""
Add request timing information to response headers.
Args:
response: FastAPI response object
start_time: Time when request processing started
"""
end_time = perf_counter()
elapsed = (end_time - start_time) * 1000 # Convert to milliseconds
response.headers.update(
{
"request-start": f"{start_time:.6f}",
"request-end": f"{end_time:.6f}",
"request-duration": f"{elapsed:.2f}ms",
}
)

View File

@ -0,0 +1,209 @@
"""
OpenAPI Schema Creator Module
This module provides functionality to create and customize OpenAPI documentation:
- Custom security schemes (Bearer Auth, API Key)
- Response schemas and examples
- Tag management and descriptions
- Error responses and validation
- Custom documentation extensions
"""
from typing import Any, Dict, List, Optional, Set
from fastapi import FastAPI, APIRouter
from fastapi.openapi.utils import get_openapi
from AllConfigs.main import MainConfig as Config
class OpenAPISchemaCreator:
"""
OpenAPI schema creator and customizer for FastAPI applications.
"""
def __init__(self, app: FastAPI):
"""
Initialize the OpenAPI schema creator.
Args:
app: FastAPI application instance
"""
self.app = app
self.protected_paths: Set[str] = set()
self.tags_metadata = self._create_tags_metadata()
@staticmethod
def _create_tags_metadata() -> List[Dict[str, str]]:
"""
Create metadata for API tags.
Returns:
List[Dict[str, str]]: List of tag metadata
"""
return [
{
"name": "Authentication",
"description": "Operations related to user authentication and authorization",
},
{
"name": "Users",
"description": "User management and profile operations",
},
# Add more tags as needed
]
def _create_security_schemes(self) -> Dict[str, Any]:
"""
Create security scheme definitions.
Returns:
Dict[str, Any]: Security scheme configurations
"""
return {
"Bearer Auth": {
"type": "apiKey",
"in": "header",
"name": "evyos-session-key",
"description": "Enter: **'Bearer <JWT>'**, where JWT is the access token",
},
"API Key": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API key for service authentication",
},
}
def _create_common_responses(self) -> Dict[str, Any]:
"""
Create common response schemas.
Returns:
Dict[str, Any]: Common response configurations
"""
return {
"401": {
"description": "Unauthorized - Authentication failed or not provided",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"detail": {"type": "string"},
"error_code": {"type": "string"},
},
},
"example": {
"detail": "Invalid authentication credentials",
"error_code": "INVALID_CREDENTIALS",
},
}
},
},
"403": {
"description": "Forbidden - Insufficient permissions",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/HTTPValidationError"}
}
},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/HTTPValidationError"}
}
},
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"detail": {"type": "string"},
"error_code": {"type": "string"},
},
},
"example": {
"detail": "Internal server error occurred",
"error_code": "INTERNAL_ERROR",
},
}
},
},
}
def configure_route_security(
self, path: str, method: str, schema: Dict[str, Any]
) -> None:
"""
Configure security requirements for a specific route.
Args:
path: Route path
method: HTTP method
schema: OpenAPI schema to modify
"""
if path not in Config.INSECURE_PATHS:
schema["paths"][path][method]["security"] = [
{"Bearer Auth": []},
{"API Key": []},
]
schema["paths"][path][method]["responses"].update(
self._create_common_responses()
)
def create_schema(self) -> Dict[str, Any]:
"""
Create the complete OpenAPI schema.
Returns:
Dict[str, Any]: Complete OpenAPI schema
"""
openapi_schema = get_openapi(
title=Config.TITLE,
description=Config.DESCRIPTION,
version="1.0.0",
routes=self.app.routes,
tags=self.tags_metadata,
)
# Add security schemes
if "components" not in openapi_schema:
openapi_schema["components"] = {}
openapi_schema["components"]["securitySchemes"] = self._create_security_schemes()
# Configure route security and responses
for route in self.app.routes:
if isinstance(route, APIRoute) and route.include_in_schema:
path = str(route.path)
methods = [method.lower() for method in route.methods]
for method in methods:
self.configure_route_security(path, method, openapi_schema)
# Add custom documentation extensions
openapi_schema["x-documentation"] = {
"postman_collection": "/docs/postman",
"swagger_ui": "/docs",
"redoc": "/redoc",
}
return openapi_schema
def create_openapi_schema(app: FastAPI) -> Dict[str, Any]:
"""
Create OpenAPI schema for a FastAPI application.
Args:
app: FastAPI application instance
Returns:
Dict[str, Any]: Complete OpenAPI schema
"""
creator = OpenAPISchemaCreator(app)
return creator.create_schema()

View File

@ -0,0 +1,5 @@
from .base_router import test_route
__all__ = [
"test_route"
]

View File

@ -0,0 +1,22 @@
"""
Base router configuration and setup.
"""
from fastapi import APIRouter, Request
from middleware.auth_middleware import MiddlewareModule
# Create test router
test_route = APIRouter(prefix="/test", tags=["Test"])
@test_route.get("/health")
@MiddlewareModule.auth_required
async def health_check(request: Request):
return {"status": "healthy", "message": "Service is running"}
@test_route.get("/ping")
async def ping_test():
return {"ping": "pong", "service": "base-router"}
# Initialize and include test routes
def init_test_routes():
return test_route

View File

@ -0,0 +1,30 @@
FROM python:3.9-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy all required directories
COPY DockerApiServices/AllApiNeeds /app/
COPY ApiLibrary /app/ApiLibrary
COPY ApiValidations /app/ApiValidations
COPY AllConfigs /app/AllConfigs
COPY ErrorHandlers /app/ErrorHandlers
COPY Schemas /app/Schemas
COPY Services /app/Services
# Install Python dependencies
COPY DockerApiServices/requirements.txt /app/
RUN pip install --upgrade pip && pip install --no-cache-dir -r /app/requirements.txt
# Copy application code
COPY . .
# Set Python path to include app directory
ENV PYTHONPATH=/app
# Run the application using the configured uvicorn server
CMD ["python", "app.py"]

View File

@ -0,0 +1,15 @@
fastapi==0.104.1
uvicorn==0.24.0.post1
pydantic==2.10.5
sqlalchemy==2.0.37
psycopg2-binary==2.9.10
python-dateutil==2.9.0.post0
motor==3.3.2
redis==5.2.1
pytest==7.4.4
pytest-asyncio==0.21.2
pytest-cov==4.1.0
coverage==7.6.10
arrow==1.3.0
redmail==0.6.0
sqlalchemy-mixins==2.0.5

View File

@ -0,0 +1,23 @@
FROM python:3.9-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY pyproject.toml .
RUN pip install poetry && \
poetry config virtualenvs.create false && \
poetry install --no-dev
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "EventServiceApi.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -0,0 +1,59 @@
# Docker Services Guide
This repository contains multiple microservices that can be run using Docker Compose.
## Quick Start (With Cache)
For regular development when dependencies haven't changed:
```bash
# Build and run Auth Service
docker compose -f ../docker-compose-services.yml up auth-service
# Build and run Event Service
docker compose -f ../docker-compose-services.yml up event-service
# Build and run Validation Service
docker compose -f ../docker-compose-services.yml up validation-service
# Build and run all services
docker compose -f ../docker-compose-services.yml up
```
## Clean Build (No Cache)
Use these commands when changing Dockerfile or dependencies:
```bash
# Auth Service
docker compose -f ../docker-compose-services.yml build --no-cache auth-service && docker compose -f ../docker-compose-services.yml up auth-service
# Event Service
docker compose -f ../docker-compose-services.yml build --no-cache event-service && docker compose -f ../docker-compose-services.yml up event-service
# Validation Service
docker compose -f ../docker-compose-services.yml build --no-cache validation-service && docker compose -f ../docker-compose-services.yml up validation-service
# All Services
docker compose -f ../docker-compose-services.yml build --no-cache && docker compose -f ../docker-compose-services.yml up
```
## Service Ports
- Auth Service: `http://localhost:8000`
- `/test/health` - Protected health check endpoint (requires authentication)
- `/test/ping` - Public ping endpoint
- Event Service: `http://localhost:8001`
- Validation Service: `http://localhost:8002`
## Development Notes
- Use clean build (--no-cache) when:
- Changing Dockerfile
- Updating dependencies
- Experiencing caching issues
- Use regular build (with cache) when:
- Only changing application code
- For faster development iterations
- Run in detached mode:
```bash
docker compose -f ../docker-compose-services.yml up -d auth-service
```
- Stop services:
```bash
docker compose -f ../docker-compose-services.yml down
```

View File

@ -0,0 +1,23 @@
FROM python:3.9-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY pyproject.toml .
RUN pip install poetry && \
poetry config virtualenvs.create false && \
poetry install --no-dev
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "ValidationServiceApi.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -0,0 +1,76 @@
[tool.poetry]
name = "wag-management-api-services"
version = "0.1.1"
description = "WAG Management API Service"
authors = ["Karatay Berkay <karatay.berkay@evyos.com.tr>"]
[tool.poetry.dependencies]
python = "^3.9"
# FastAPI and Web
fastapi = "^0.104.1"
uvicorn = "^0.24.0"
pydantic = "^2.5.2"
# MongoDB
motor = "3.3.2" # Pinned version
pymongo = "4.5.0" # Pinned version to match motor
# PostgreSQL
sqlalchemy = "^2.0.23"
sqlalchemy-mixins = "^2.0.5"
psycopg2-binary = "^2.9.9"
# Redis
redis = "^5.0.1"
arrow = "^1.3.0"
# Email
redmail = "^0.6.0"
# Testing
pytest = "^7.4.3"
pytest-asyncio = "^0.21.1"
pytest-cov = "^4.1.0"
# Utilities
python-dateutil = "^2.8.2"
typing-extensions = "^4.8.0"
[tool.poetry.group.dev.dependencies]
black = "^23.11.0"
isort = "^5.12.0"
mypy = "^1.7.1"
flake8 = "^6.1.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 88
target-version = ['py39']
include = '\.pyi?$'
[tool.isort]
profile = "black"
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
line_length = 88
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
check_untyped_defs = true
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --cov=Services"
testpaths = [
"Ztest",
]
python_files = ["test_*.py"]
asyncio_mode = "auto"

View File

@ -0,0 +1,17 @@
fastapi==0.104.1
uvicorn==0.24.0.post1
pydantic==2.10.5
sqlalchemy==2.0.37
psycopg2-binary==2.9.10
python-dateutil==2.9.0.post0
motor==3.3.2
redis==5.2.1
pytest==7.4.4
pytest-asyncio==0.21.2
pytest-cov==4.1.0
coverage==7.6.10
arrow==1.3.0
redmail==0.6.0
sqlalchemy-mixins==2.0.5
prometheus-client==0.19.0
prometheus-fastapi-instrumentator==6.1.0

View File

@ -1,12 +1,29 @@
What to do with services? WAG Management API Microservices Setup
* 1. Authentication Service (Port 8000)
- User authentication and authorization
- JWT token management
- Role-based access control
- Uses PostgreSQL for user data
* 2. Event Service (Port 8001)
- Event processing and handling
- Message queue integration
- Real-time notifications
- Uses MongoDB for event storage
* 3. Validation Service (Port 8002)
- Request validation
- Data sanitization
- Schema validation
- Uses Redis for caching
* To run the services:
```bash
* docker compose up --build
```
Access services at:
- Auth Service: http://localhost:8000
- Event Service: http://localhost:8001
- Validation Service: http://localhost:8002

View File

@ -1,3 +1,84 @@
# wag-managment-api-service-version-4 # WAG Management API Service v4
wag managment api service version 4 This service provides a comprehensive API for managing WAG (Wide Area Gateway) systems. It handles configuration, monitoring, and control operations for WAG devices in the network infrastructure.
## Quick Start
To run the tests using Docker Compose:
```bash
docker compose -f docker-compose.test.yml up --build
```
## Project Structure
### Core Services and Components
- `Services/` - Core service implementations
- `PostgresDb/` - PostgreSQL database operations and models
- `MongoDb/` - MongoDB operations and document models
- `Redis/` - Redis caching and session management
- `Email/` - Email notification service
- `ApiValidations/` - Request validation and data sanitization
- Input validation rules
- Data sanitization filters
- Schema validation middleware
- `ApiLibrary/` - Common utilities and helper functions
- Shared functions and utilities
- Common constants and configurations
- Helper classes and decorators
### Configuration and Settings
- `AllConfigs/` - Configuration management
- Database configurations
- Service settings
- Environment-specific configs
- `Schemas/` - Data models and schema definitions
- Request/Response models
- Database schemas
- API contract definitions
### Docker and Deployment
- `DockerApiServices/` - API service Docker configurations
- API service Dockerfile
- Service dependencies
- `DockerStoreServices/` - Storage service Docker configurations
- Database service Dockerfiles
- Storage service dependencies
### Error Handling and Events
- `ErrorHandlers/` - Error handling and exception management
- Custom exceptions
- Error handlers
- Exception middleware
- `ApiEvents/` - Event handling and processing
- Event listeners
- Event dispatchers
- Message queue handlers
### Language and Testing
- `LanguageModels/` - Localization and language support
- Language files
- Translation models
- i18n configurations
- `Ztest/` - Test suite
- Unit tests
- Integration tests
- Test fixtures and utilities
### Additional Components
- `scripts/` - Utility scripts and tools
- Deployment scripts
- Database migrations
- Maintenance utilities

View File

@ -11,7 +11,7 @@ from ApiLibrary import system_arrow
class PyObjectId(ObjectId): class PyObjectId(ObjectId):
"""Custom type for handling MongoDB ObjectId in Pydantic models.""" """Custom type for handling MongoDB ObjectId in Pydantic models."""
@classmethod @classmethod
def __get_pydantic_core_schema__( def __get_pydantic_core_schema__(
cls, cls,
@ -21,17 +21,21 @@ class PyObjectId(ObjectId):
"""Define the core schema for PyObjectId.""" """Define the core schema for PyObjectId."""
return core_schema.json_or_python_schema( return core_schema.json_or_python_schema(
json_schema=core_schema.str_schema(), json_schema=core_schema.str_schema(),
python_schema=core_schema.union_schema([ python_schema=core_schema.union_schema(
core_schema.is_instance_schema(ObjectId), [
core_schema.chain_schema([ core_schema.is_instance_schema(ObjectId),
core_schema.str_schema(), core_schema.chain_schema(
core_schema.no_info_plain_validator_function(cls.validate), [
]), core_schema.str_schema(),
]), core_schema.no_info_plain_validator_function(cls.validate),
]
),
]
),
serialization=core_schema.plain_serializer_function_ser_schema( serialization=core_schema.plain_serializer_function_ser_schema(
lambda x: str(x), lambda x: str(x),
return_schema=core_schema.str_schema(), return_schema=core_schema.str_schema(),
when_used='json', when_used="json",
), ),
) )
@ -54,14 +58,14 @@ class PyObjectId(ObjectId):
class MongoBaseModel(BaseModel): class MongoBaseModel(BaseModel):
"""Base model for all MongoDB documents.""" """Base model for all MongoDB documents."""
model_config = ConfigDict( model_config = ConfigDict(
arbitrary_types_allowed=True, arbitrary_types_allowed=True,
json_encoders={ObjectId: str}, json_encoders={ObjectId: str},
populate_by_name=True, populate_by_name=True,
from_attributes=True, from_attributes=True,
validate_assignment=True, validate_assignment=True,
extra='allow' extra="allow",
) )
# Optional _id field that will be ignored in create operations # Optional _id field that will be ignored in create operations
@ -69,11 +73,11 @@ class MongoBaseModel(BaseModel):
def get_extra(self, field_name: str, default: Any = None) -> Any: def get_extra(self, field_name: str, default: Any = None) -> Any:
"""Safely get extra field value. """Safely get extra field value.
Args: Args:
field_name: Name of the extra field to retrieve field_name: Name of the extra field to retrieve
default: Default value to return if field doesn't exist default: Default value to return if field doesn't exist
Returns: Returns:
Value of the extra field if it exists, otherwise the default value Value of the extra field if it exists, otherwise the default value
""" """
@ -81,7 +85,7 @@ class MongoBaseModel(BaseModel):
def as_dict(self) -> Dict[str, Any]: def as_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary including all fields and extra fields. """Convert model to dictionary including all fields and extra fields.
Returns: Returns:
Dict containing all model fields and extra fields with proper type conversion Dict containing all model fields and extra fields with proper type conversion
""" """
@ -94,18 +98,18 @@ class MongoDocument(MongoBaseModel):
created_at: float = Field(default_factory=lambda: system_arrow.now().timestamp()) created_at: float = Field(default_factory=lambda: system_arrow.now().timestamp())
updated_at: float = Field(default_factory=lambda: system_arrow.now().timestamp()) updated_at: float = Field(default_factory=lambda: system_arrow.now().timestamp())
@model_validator(mode='before') @model_validator(mode="before")
@classmethod @classmethod
def prevent_protected_fields(cls, data: Any) -> Any: def prevent_protected_fields(cls, data: Any) -> Any:
"""Prevent user from setting protected fields like _id and timestamps.""" """Prevent user from setting protected fields like _id and timestamps."""
if isinstance(data, dict): if isinstance(data, dict):
# Remove protected fields from input # Remove protected fields from input
data.pop('_id', None) data.pop("_id", None)
data.pop('created_at', None) data.pop("created_at", None)
data.pop('updated_at', None) data.pop("updated_at", None)
# Set timestamps # Set timestamps
data['created_at'] = system_arrow.now().timestamp() data["created_at"] = system_arrow.now().timestamp()
data['updated_at'] = system_arrow.now().timestamp() data["updated_at"] = system_arrow.now().timestamp()
return data return data

View File

@ -7,7 +7,7 @@ including domain history and access details.
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from pydantic import Field, model_validator from pydantic import BaseModel, Field, ConfigDict, model_validator
from ApiLibrary import system_arrow from ApiLibrary import system_arrow
from Services.MongoDb.Models.action_models.base import MongoBaseModel, MongoDocument from Services.MongoDb.Models.action_models.base import MongoBaseModel, MongoDocument
@ -15,40 +15,38 @@ from Services.MongoDb.Models.action_models.base import MongoBaseModel, MongoDocu
class DomainData(MongoBaseModel): class DomainData(MongoBaseModel):
"""Model for domain data. """Model for domain data.
Attributes: Attributes:
user_uu_id: Unique identifier of the user user_uu_id: Unique identifier of the user
main_domain: Primary domain main_domain: Primary domain
other_domains_list: List of additional domains other_domains_list: List of additional domains
extra_data: Additional domain-related data extra_data: Additional domain-related data
""" """
user_uu_id: str = Field(..., description="User's unique identifier") user_uu_id: str = Field(..., description="User's unique identifier")
main_domain: str = Field(..., description="Primary domain") main_domain: str = Field(..., description="Primary domain")
other_domains_list: List[str] = Field( other_domains_list: List[str] = Field(
default_factory=list, default_factory=list, description="List of additional domains"
description="List of additional domains"
) )
extra_data: Optional[Dict[str, Any]] = Field( extra_data: Optional[Dict[str, Any]] = Field(
default_factory=dict, default_factory=dict,
alias="extraData", alias="extraData",
description="Additional domain-related data" description="Additional domain-related data",
) )
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True, populate_by_name=True, validate_assignment=True
populate_by_name = True )
validate_assignment = True
class DomainDocument(MongoDocument): class DomainDocument(MongoDocument):
"""Model for domain-related documents.""" """Model for domain-related documents."""
data: DomainData = Field(..., description="Domain data") data: DomainData = Field(..., description="Domain data")
def update_main_domain(self, new_domain: str) -> None: def update_main_domain(self, new_domain: str) -> None:
"""Update the main domain and move current to history. """Update the main domain and move current to history.
Args: Args:
new_domain: New main domain to set new_domain: New main domain to set
""" """
@ -60,19 +58,19 @@ class DomainDocument(MongoDocument):
class DomainDocumentCreate(MongoDocument): class DomainDocumentCreate(MongoDocument):
"""Model for creating new domain documents.""" """Model for creating new domain documents."""
data: DomainData = Field(..., description="Initial domain data") data: DomainData = Field(..., description="Initial domain data")
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True, populate_by_name=True, validate_assignment=True
populate_by_name = True )
validate_assignment = True
class DomainDocumentUpdate(MongoDocument): class DomainDocumentUpdate(MongoDocument):
"""Model for updating existing domain documents.""" """Model for updating existing domain documents."""
data: DomainData = Field(..., description="Updated domain data") data: DomainData = Field(..., description="Updated domain data")
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True, populate_by_name=True, validate_assignment=True
populate_by_name = True )
validate_assignment = True

View File

@ -15,7 +15,7 @@ from Services.MongoDb.Models.action_models.base import MongoBaseModel, MongoDocu
class PasswordHistoryDetail(MongoBaseModel): class PasswordHistoryDetail(MongoBaseModel):
"""Model for password history details.""" """Model for password history details."""
timestamp: datetime timestamp: datetime
ip_address: Optional[str] = Field(None, alias="ipAddress") ip_address: Optional[str] = Field(None, alias="ipAddress")
user_agent: Optional[str] = Field(None, alias="userAgent") user_agent: Optional[str] = Field(None, alias="userAgent")
@ -24,27 +24,26 @@ class PasswordHistoryDetail(MongoBaseModel):
class PasswordHistoryData(MongoBaseModel): class PasswordHistoryData(MongoBaseModel):
"""Model for password history data.""" """Model for password history data."""
password_history: List[str] = Field([], alias="passwordHistory") password_history: List[str] = Field([], alias="passwordHistory")
access_history_detail: Dict[str, PasswordHistoryDetail] = Field( access_history_detail: Dict[str, PasswordHistoryDetail] = Field(
default_factory=dict, default_factory=dict, alias="accessHistoryDetail"
alias="accessHistoryDetail"
) )
class PasswordDocument(MongoDocument): class PasswordDocument(MongoDocument):
"""Model for password-related documents.""" """Model for password-related documents."""
data: PasswordHistoryData data: PasswordHistoryData
class PasswordDocumentCreate(MongoBaseModel): class PasswordDocumentCreate(MongoBaseModel):
"""Model for creating new password documents.""" """Model for creating new password documents."""
data: PasswordHistoryData = Field(..., description="Initial password data") data: PasswordHistoryData = Field(..., description="Initial password data")
class PasswordDocumentUpdate(MongoBaseModel): class PasswordDocumentUpdate(MongoBaseModel):
"""Model for updating existing password documents.""" """Model for updating existing password documents."""
data: PasswordHistoryData data: PasswordHistoryData

View File

@ -30,23 +30,19 @@ class MongoActions(
MongoInsertMixin, MongoInsertMixin,
MongoFindMixin, MongoFindMixin,
MongoDeleteMixin, MongoDeleteMixin,
MongoAggregateMixin MongoAggregateMixin,
): ):
"""Main MongoDB actions class that inherits all CRUD operation mixins. """Main MongoDB actions class that inherits all CRUD operation mixins.
This class provides a unified interface for all MongoDB operations while This class provides a unified interface for all MongoDB operations while
managing collections based on company UUID and storage reason. managing collections based on company UUID and storage reason.
""" """
def __init__( def __init__(
self, self, client: MongoClient, database: str, company_uuid: str, storage_reason: str
client: MongoClient,
database: str,
company_uuid: str,
storage_reason: str
): ):
"""Initialize MongoDB actions with client and collection info. """Initialize MongoDB actions with client and collection info.
Args: Args:
client: MongoDB client client: MongoDB client
database: Database name to use database: Database name to use
@ -62,7 +58,7 @@ class MongoActions(
def use_collection(self, storage_reason: str) -> None: def use_collection(self, storage_reason: str) -> None:
"""Switch to a different collection. """Switch to a different collection.
Args: Args:
storage_reason: New storage reason for collection naming storage_reason: New storage reason for collection naming
""" """
@ -82,7 +78,9 @@ class MongoActions(
"""Insert multiple documents.""" """Insert multiple documents."""
return super().insert_many(self.collection, documents) return super().insert_many(self.collection, documents)
def find_one(self, filter_query: Dict[str, Any], projection: Optional[Dict[str, Any]] = None): def find_one(
self, filter_query: Dict[str, Any], projection: Optional[Dict[str, Any]] = None
):
"""Find a single document.""" """Find a single document."""
return super().find_one(self.collection, filter_query, projection) return super().find_one(self.collection, filter_query, projection)
@ -96,12 +94,7 @@ class MongoActions(
): ):
"""Find multiple documents.""" """Find multiple documents."""
return super().find_many( return super().find_many(
self.collection, self.collection, filter_query, projection, sort, limit, skip
filter_query,
projection,
sort,
limit,
skip
) )
def update_one( def update_one(
@ -111,12 +104,7 @@ class MongoActions(
upsert: bool = False, upsert: bool = False,
): ):
"""Update a single document.""" """Update a single document."""
return super().update_one( return super().update_one(self.collection, filter_query, update_data, upsert)
self.collection,
filter_query,
update_data,
upsert
)
def update_many( def update_many(
self, self,
@ -125,12 +113,7 @@ class MongoActions(
upsert: bool = False, upsert: bool = False,
): ):
"""Update multiple documents.""" """Update multiple documents."""
return super().update_many( return super().update_many(self.collection, filter_query, update_data, upsert)
self.collection,
filter_query,
update_data,
upsert
)
def delete_one(self, filter_query: Dict[str, Any]): def delete_one(self, filter_query: Dict[str, Any]):
"""Delete a single document.""" """Delete a single document."""

View File

@ -19,27 +19,27 @@ from Services.MongoDb.Models.exceptions import (
PasswordHistoryError, PasswordHistoryError,
PasswordReuseError, PasswordReuseError,
PasswordHistoryLimitError, PasswordHistoryLimitError,
InvalidPasswordDetailError InvalidPasswordDetailError,
) )
from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi
def handle_mongo_errors(func: Callable) -> Callable: def handle_mongo_errors(func: Callable) -> Callable:
"""Decorator to handle MongoDB operation errors. """Decorator to handle MongoDB operation errors.
Args: Args:
func: Function to wrap with error handling func: Function to wrap with error handling
Returns: Returns:
Wrapped function with error handling Wrapped function with error handling
""" """
async def wrapper(*args, **kwargs) -> Any: async def wrapper(*args, **kwargs) -> Any:
try: try:
return await func(*args, **kwargs) return await func(*args, **kwargs)
except ConnectionFailure as e: except ConnectionFailure as e:
raise MongoConnectionError( raise MongoConnectionError(
message=str(e), message=str(e), details={"error_type": "connection_failure"}
details={"error_type": "connection_failure"}
).to_http_exception() ).to_http_exception()
except DuplicateKeyError as e: except DuplicateKeyError as e:
raise MongoDuplicateKeyError( raise MongoDuplicateKeyError(
@ -48,143 +48,138 @@ def handle_mongo_errors(func: Callable) -> Callable:
).to_http_exception() ).to_http_exception()
except PyMongoError as e: except PyMongoError as e:
raise MongoBaseException( raise MongoBaseException(
message=str(e), message=str(e), details={"error_type": "pymongo_error"}
details={"error_type": "pymongo_error"}
).to_http_exception() ).to_http_exception()
except Exception as e: except Exception as e:
raise HTTPExceptionApi( raise HTTPExceptionApi(
lang="en", lang="en",
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR, error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
return wrapper return wrapper
async def mongo_base_exception_handler( async def mongo_base_exception_handler(
request: Request, request: Request, exc: MongoBaseException
exc: MongoBaseException
) -> JSONResponse: ) -> JSONResponse:
"""Handle base MongoDB exceptions. """Handle base MongoDB exceptions.
Args: Args:
request: FastAPI request request: FastAPI request
exc: MongoDB base exception exc: MongoDB base exception
Returns: Returns:
JSON response with error details JSON response with error details
""" """
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code, content={"error": exc.to_http_exception()}
content={"error": exc.to_http_exception()}
) )
async def mongo_connection_error_handler( async def mongo_connection_error_handler(
request: Request, request: Request, exc: MongoConnectionError
exc: MongoConnectionError
) -> JSONResponse: ) -> JSONResponse:
"""Handle MongoDB connection errors. """Handle MongoDB connection errors.
Args: Args:
request: FastAPI request request: FastAPI request
exc: MongoDB connection error exc: MongoDB connection error
Returns: Returns:
JSON response with connection error details JSON response with connection error details
""" """
return JSONResponse( return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"error": exc.to_http_exception()} content={"error": exc.to_http_exception()},
) )
async def mongo_document_not_found_handler( async def mongo_document_not_found_handler(
request: Request, request: Request, exc: MongoDocumentNotFoundError
exc: MongoDocumentNotFoundError
) -> JSONResponse: ) -> JSONResponse:
"""Handle document not found errors. """Handle document not found errors.
Args: Args:
request: FastAPI request request: FastAPI request
exc: Document not found error exc: Document not found error
Returns: Returns:
JSON response with not found error details JSON response with not found error details
""" """
return JSONResponse( return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
content={"error": exc.to_http_exception()} content={"error": exc.to_http_exception()},
) )
async def mongo_validation_error_handler( async def mongo_validation_error_handler(
request: Request, request: Request, exc: MongoValidationError
exc: MongoValidationError
) -> JSONResponse: ) -> JSONResponse:
"""Handle validation errors. """Handle validation errors.
Args: Args:
request: FastAPI request request: FastAPI request
exc: Validation error exc: Validation error
Returns: Returns:
JSON response with validation error details JSON response with validation error details
""" """
return JSONResponse( return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"error": exc.to_http_exception()} content={"error": exc.to_http_exception()},
) )
async def mongo_duplicate_key_error_handler( async def mongo_duplicate_key_error_handler(
request: Request, request: Request, exc: MongoDuplicateKeyError
exc: MongoDuplicateKeyError
) -> JSONResponse: ) -> JSONResponse:
"""Handle duplicate key errors. """Handle duplicate key errors.
Args: Args:
request: FastAPI request request: FastAPI request
exc: Duplicate key error exc: Duplicate key error
Returns: Returns:
JSON response with duplicate key error details JSON response with duplicate key error details
""" """
return JSONResponse( return JSONResponse(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT, content={"error": exc.to_http_exception()}
content={"error": exc.to_http_exception()}
) )
async def password_history_error_handler( async def password_history_error_handler(
request: Request, request: Request, exc: PasswordHistoryError
exc: PasswordHistoryError
) -> JSONResponse: ) -> JSONResponse:
"""Handle password history errors. """Handle password history errors.
Args: Args:
request: FastAPI request request: FastAPI request
exc: Password history error exc: Password history error
Returns: Returns:
JSON response with password history error details JSON response with password history error details
""" """
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code, content={"error": exc.to_http_exception()}
content={"error": exc.to_http_exception()}
) )
def register_exception_handlers(app: Any) -> None: def register_exception_handlers(app: Any) -> None:
"""Register all MongoDB exception handlers with FastAPI app. """Register all MongoDB exception handlers with FastAPI app.
Args: Args:
app: FastAPI application instance app: FastAPI application instance
""" """
app.add_exception_handler(MongoBaseException, mongo_base_exception_handler) app.add_exception_handler(MongoBaseException, mongo_base_exception_handler)
app.add_exception_handler(MongoConnectionError, mongo_connection_error_handler) app.add_exception_handler(MongoConnectionError, mongo_connection_error_handler)
app.add_exception_handler(MongoDocumentNotFoundError, mongo_document_not_found_handler) app.add_exception_handler(
MongoDocumentNotFoundError, mongo_document_not_found_handler
)
app.add_exception_handler(MongoValidationError, mongo_validation_error_handler) app.add_exception_handler(MongoValidationError, mongo_validation_error_handler)
app.add_exception_handler(MongoDuplicateKeyError, mongo_duplicate_key_error_handler) app.add_exception_handler(MongoDuplicateKeyError, mongo_duplicate_key_error_handler)
app.add_exception_handler(PasswordHistoryError, password_history_error_handler) app.add_exception_handler(PasswordHistoryError, password_history_error_handler)
app.add_exception_handler(PasswordReuseError, password_history_error_handler) app.add_exception_handler(PasswordReuseError, password_history_error_handler)
app.add_exception_handler(PasswordHistoryLimitError, password_history_error_handler) app.add_exception_handler(PasswordHistoryLimitError, password_history_error_handler)
app.add_exception_handler(InvalidPasswordDetailError, password_history_error_handler) app.add_exception_handler(
InvalidPasswordDetailError, password_history_error_handler
)

View File

@ -12,12 +12,12 @@ from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi
class MongoBaseException(Exception): class MongoBaseException(Exception):
"""Base exception for MongoDB-related errors.""" """Base exception for MongoDB-related errors."""
def __init__( def __init__(
self, self,
message: str, message: str,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
details: Optional[Dict[str, Any]] = None details: Optional[Dict[str, Any]] = None,
): ):
self.message = message self.message = message
self.status_code = status_code self.status_code = status_code
@ -34,128 +34,110 @@ class MongoBaseException(Exception):
class MongoConnectionError(MongoBaseException): class MongoConnectionError(MongoBaseException):
"""Raised when there's an error connecting to MongoDB.""" """Raised when there's an error connecting to MongoDB."""
def __init__( def __init__(
self, self,
message: str = "Failed to connect to MongoDB", message: str = "Failed to connect to MongoDB",
details: Optional[Dict[str, Any]] = None details: Optional[Dict[str, Any]] = None,
): ):
super().__init__( super().__init__(
message=message, message=message,
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
details=details details=details,
) )
class MongoDocumentNotFoundError(MongoBaseException): class MongoDocumentNotFoundError(MongoBaseException):
"""Raised when a document is not found in MongoDB.""" """Raised when a document is not found in MongoDB."""
def __init__( def __init__(
self, self,
collection: str, collection: str,
filter_query: Dict[str, Any], filter_query: Dict[str, Any],
message: Optional[str] = None message: Optional[str] = None,
): ):
message = message or f"Document not found in collection '{collection}'" message = message or f"Document not found in collection '{collection}'"
super().__init__( super().__init__(
message=message, message=message,
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
details={ details={"collection": collection, "filter": filter_query},
"collection": collection,
"filter": filter_query
}
) )
class MongoValidationError(MongoBaseException): class MongoValidationError(MongoBaseException):
"""Raised when document validation fails.""" """Raised when document validation fails."""
def __init__( def __init__(self, message: str, field_errors: Optional[Dict[str, str]] = None):
self,
message: str,
field_errors: Optional[Dict[str, str]] = None
):
super().__init__( super().__init__(
message=message, message=message,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
details={"field_errors": field_errors or {}} details={"field_errors": field_errors or {}},
) )
class MongoDuplicateKeyError(MongoBaseException): class MongoDuplicateKeyError(MongoBaseException):
"""Raised when trying to insert a document with a duplicate key.""" """Raised when trying to insert a document with a duplicate key."""
def __init__( def __init__(
self, self,
collection: str, collection: str,
key_pattern: Dict[str, Any], key_pattern: Dict[str, Any],
message: Optional[str] = None message: Optional[str] = None,
): ):
message = message or f"Duplicate key error in collection '{collection}'" message = message or f"Duplicate key error in collection '{collection}'"
super().__init__( super().__init__(
message=message, message=message,
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
details={ details={"collection": collection, "key_pattern": key_pattern},
"collection": collection,
"key_pattern": key_pattern
}
) )
class PasswordHistoryError(MongoBaseException): class PasswordHistoryError(MongoBaseException):
"""Base exception for password history-related errors.""" """Base exception for password history-related errors."""
def __init__( def __init__(
self, self,
message: str, message: str,
status_code: int = status.HTTP_400_BAD_REQUEST, status_code: int = status.HTTP_400_BAD_REQUEST,
details: Optional[Dict[str, Any]] = None details: Optional[Dict[str, Any]] = None,
): ):
super().__init__(message, status_code, details) super().__init__(message, status_code, details)
class PasswordReuseError(PasswordHistoryError): class PasswordReuseError(PasswordHistoryError):
"""Raised when attempting to reuse a recent password.""" """Raised when attempting to reuse a recent password."""
def __init__( def __init__(
self, self,
message: str = "Password was used recently", message: str = "Password was used recently",
history_limit: Optional[int] = None history_limit: Optional[int] = None,
): ):
details = {"history_limit": history_limit} if history_limit else None details = {"history_limit": history_limit} if history_limit else None
super().__init__( super().__init__(
message=message, message=message,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
details=details details=details,
) )
class PasswordHistoryLimitError(PasswordHistoryError): class PasswordHistoryLimitError(PasswordHistoryError):
"""Raised when password history limit is reached.""" """Raised when password history limit is reached."""
def __init__( def __init__(self, limit: int, message: Optional[str] = None):
self,
limit: int,
message: Optional[str] = None
):
message = message or f"Password history limit of {limit} reached" message = message or f"Password history limit of {limit} reached"
super().__init__( super().__init__(
message=message, message=message,
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
details={"limit": limit} details={"limit": limit},
) )
class InvalidPasswordDetailError(PasswordHistoryError): class InvalidPasswordDetailError(PasswordHistoryError):
"""Raised when password history detail is invalid.""" """Raised when password history detail is invalid."""
def __init__( def __init__(self, message: str, field_errors: Optional[Dict[str, str]] = None):
self,
message: str,
field_errors: Optional[Dict[str, str]] = None
):
super().__init__( super().__init__(
message=message, message=message,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
details={"field_errors": field_errors or {}} details={"field_errors": field_errors or {}},
) )

View File

@ -34,25 +34,16 @@ def handle_mongo_errors(func):
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except ConnectionFailure: except ConnectionFailure:
raise HTTPExceptionApi( raise HTTPExceptionApi(error_code="HTTP_503_SERVICE_UNAVAILABLE", lang="en")
error_code="HTTP_503_SERVICE_UNAVAILABLE",
lang="en"
)
except ServerSelectionTimeoutError: except ServerSelectionTimeoutError:
raise HTTPExceptionApi( raise HTTPExceptionApi(error_code="HTTP_504_GATEWAY_TIMEOUT", lang="en")
error_code="HTTP_504_GATEWAY_TIMEOUT",
lang="en"
)
except OperationFailure as e: except OperationFailure as e:
raise HTTPExceptionApi( raise HTTPExceptionApi(error_code="HTTP_400_BAD_REQUEST", lang="en")
error_code="HTTP_400_BAD_REQUEST",
lang="en"
)
except PyMongoError as e: except PyMongoError as e:
raise HTTPExceptionApi( raise HTTPExceptionApi(
error_code="HTTP_500_INTERNAL_SERVER_ERROR", error_code="HTTP_500_INTERNAL_SERVER_ERROR", lang="en"
lang="en"
) )
return wrapper return wrapper

View File

@ -42,11 +42,15 @@ class MongoFindMixin:
class MongoUpdateMixin: class MongoUpdateMixin:
"""Mixin for MongoDB update operations.""" """Mixin for MongoDB update operations."""
def update_one(self, filter_query: Dict[str, Any], update: Dict[str, Any]) -> UpdateResult: def update_one(
self, filter_query: Dict[str, Any], update: Dict[str, Any]
) -> UpdateResult:
"""Update a single document.""" """Update a single document."""
return self.collection.update_one(filter_query, update) return self.collection.update_one(filter_query, update)
def update_many(self, filter_query: Dict[str, Any], update: Dict[str, Any]) -> UpdateResult: def update_many(
self, filter_query: Dict[str, Any], update: Dict[str, Any]
) -> UpdateResult:
"""Update multiple documents.""" """Update multiple documents."""
return self.collection.update_many(filter_query, update) return self.collection.update_many(filter_query, update)
@ -100,7 +104,7 @@ class MongoDBHandler(
) )
else: else:
connection_url = f"mongodb://{MongoConfig.HOST}:{MongoConfig.PORT}" connection_url = f"mongodb://{MongoConfig.HOST}:{MongoConfig.PORT}"
# Build connection options # Build connection options
connection_kwargs = { connection_kwargs = {
"host": connection_url, "host": connection_url,
@ -110,11 +114,11 @@ class MongoDBHandler(
"waitQueueTimeoutMS": 2000, # How long a thread will wait for a connection "waitQueueTimeoutMS": 2000, # How long a thread will wait for a connection
"serverSelectionTimeoutMS": 5000, # How long to wait for server selection "serverSelectionTimeoutMS": 5000, # How long to wait for server selection
} }
self._client = MongoClient(**connection_kwargs) self._client = MongoClient(**connection_kwargs)
# Test connection # Test connection
self._client.admin.command('ping') self._client.admin.command("ping")
def close(self): def close(self):
"""Close MongoDB connection.""" """Close MongoDB connection."""

View File

@ -3,8 +3,7 @@ from functools import lru_cache
from typing import Generator from typing import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declarative_base, sessionmaker, scoped_session, Session
from sqlalchemy.orm import sessionmaker, scoped_session, Session
from AllConfigs.SqlDatabase.configs import WagDatabase from AllConfigs.SqlDatabase.configs import WagDatabase

13
Ztest/fixtures.py Normal file
View File

@ -0,0 +1,13 @@
"""Test fixtures and models."""
from sqlalchemy import Column, String
from Services.PostgresDb.Models.mixins import CrudCollection
class TestUser(CrudCollection):
"""Test user model for PostgreSQL tests."""
__tablename__ = "test_users"
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)

13
Ztest/models.py Normal file
View File

@ -0,0 +1,13 @@
"""Test models."""
from sqlalchemy import Column, String
from Services.PostgresDb.Models.mixins import CrudCollection
class UserModel(CrudCollection):
"""User model for PostgreSQL tests."""
__tablename__ = "test_users"
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)

View File

@ -7,7 +7,7 @@ from Services.MongoDb.Models.actions import MongoActions
from Services.MongoDb.Models.action_models.domain import ( from Services.MongoDb.Models.action_models.domain import (
DomainData, DomainData,
DomainDocumentCreate, DomainDocumentCreate,
DomainDocumentUpdate DomainDocumentUpdate,
) )
from AllConfigs.NoSqlDatabase.configs import MongoConfig from AllConfigs.NoSqlDatabase.configs import MongoConfig
@ -17,7 +17,7 @@ def mongo_client():
"""Create MongoDB test client.""" """Create MongoDB test client."""
# Connect using configured credentials # Connect using configured credentials
client = MongoClient(MongoConfig.URL) client = MongoClient(MongoConfig.URL)
client.admin.command('ping') # Test connection client.admin.command("ping") # Test connection
yield client yield client
client.close() client.close()
@ -27,12 +27,12 @@ def mongo_actions(mongo_client):
"""Create MongoActions instance for testing.""" """Create MongoActions instance for testing."""
if not mongo_client: if not mongo_client:
pytest.skip("MongoDB connection not available") pytest.skip("MongoDB connection not available")
actions = MongoActions( actions = MongoActions(
client=mongo_client, client=mongo_client,
database=MongoConfig.DATABASE_NAME, database=MongoConfig.DATABASE_NAME,
company_uuid="test_company", company_uuid="test_company",
storage_reason="domains" storage_reason="domains",
) )
yield actions yield actions
try: try:
@ -45,37 +45,36 @@ def mongo_actions(mongo_client):
def test_mongo_crud_operations(mongo_actions: MongoActions): def test_mongo_crud_operations(mongo_actions: MongoActions):
"""Test CRUD operations with MongoActions.""" """Test CRUD operations with MongoActions."""
# Create test data # Create test data
domain_data = DomainData( domain_data = DomainData(
user_uu_id="test_user", user_uu_id="test_user",
main_domain="example.com", main_domain="example.com",
other_domains_list=["old.com"] other_domains_list=["old.com"],
) )
create_doc = DomainDocumentCreate(data=domain_data) create_doc = DomainDocumentCreate(data=domain_data)
# Test create # Test create
result = mongo_actions.insert_one(create_doc.model_dump()) result = mongo_actions.insert_one(create_doc.model_dump())
assert result.inserted_id is not None assert result.inserted_id is not None
# Test read # Test read
doc = mongo_actions.find_one({"data.main_domain": "example.com"}) doc = mongo_actions.find_one({"data.main_domain": "example.com"})
assert doc is not None assert doc is not None
assert doc["data"]["main_domain"] == "example.com" assert doc["data"]["main_domain"] == "example.com"
# Test update # Test update
update_data = DomainData( update_data = DomainData(
user_uu_id="test_user", user_uu_id="test_user",
main_domain="new.com", main_domain="new.com",
other_domains_list=["example.com", "old.com"] other_domains_list=["example.com", "old.com"],
) )
update_doc = DomainDocumentUpdate(data=update_data) update_doc = DomainDocumentUpdate(data=update_data)
result = mongo_actions.update_one( result = mongo_actions.update_one(
{"_id": doc["_id"]}, {"_id": doc["_id"]}, {"$set": update_doc.model_dump()}
{"$set": update_doc.model_dump()}
) )
assert result.modified_count == 1 assert result.modified_count == 1
# Test delete # Test delete
result = mongo_actions.delete_one({"_id": doc["_id"]}) result = mongo_actions.delete_one({"_id": doc["_id"]})
assert result.deleted_count == 1 assert result.deleted_count == 1
@ -83,23 +82,18 @@ def test_mongo_crud_operations(mongo_actions: MongoActions):
def test_mongo_aggregate(mongo_actions: MongoActions): def test_mongo_aggregate(mongo_actions: MongoActions):
"""Test aggregate operations with MongoActions.""" """Test aggregate operations with MongoActions."""
# Insert test documents # Insert test documents
docs = [ docs = [
DomainDocumentCreate( DomainDocumentCreate(
data=DomainData( data=DomainData(user_uu_id="user1", main_domain=f"domain{i}.com")
user_uu_id="user1",
main_domain=f"domain{i}.com"
)
).model_dump() ).model_dump()
for i in range(3) for i in range(3)
] ]
mongo_actions.insert_many(docs) mongo_actions.insert_many(docs)
# Test aggregation # Test aggregation
pipeline = [ pipeline = [{"$group": {"_id": "$data.user_uu_id", "count": {"$sum": 1}}}]
{"$group": {"_id": "$data.user_uu_id", "count": {"$sum": 1}}}
]
result = mongo_actions.aggregate(pipeline) result = mongo_actions.aggregate(pipeline)
result_list = list(result) result_list = list(result)
assert len(result_list) == 1 assert len(result_list) == 1
@ -107,4 +101,4 @@ def test_mongo_aggregate(mongo_actions: MongoActions):
if __name__ == "__main__": if __name__ == "__main__":
pytest.main([__file__, "-v"]) pytest.main([__file__, "-v"])

View File

@ -1,21 +1,12 @@
"""Test PostgreSQL database operations.""" """Test PostgreSQL database operations."""
import pytest import pytest
from sqlalchemy import Column, String, create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from Services.PostgresDb.database import Base, get_db from Services.PostgresDb.database import Base, get_db
from Services.PostgresDb.Models.mixins import CrudCollection
from AllConfigs.SqlDatabase.configs import WagDatabase from AllConfigs.SqlDatabase.configs import WagDatabase
from Ztest.models import UserModel
class TestUser(CrudCollection):
"""Test user model for PostgreSQL tests."""
__tablename__ = "test_users"
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@ -24,12 +15,12 @@ def db_engine():
# Use the same database URL but with test database # Use the same database URL but with test database
test_db_url = WagDatabase.DATABASE_URL test_db_url = WagDatabase.DATABASE_URL
engine = create_engine(test_db_url, echo=True) engine = create_engine(test_db_url, echo=True)
# Create all tables # Create all tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
yield engine yield engine
# Drop all tables after tests # Drop all tables after tests
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
@ -40,9 +31,9 @@ def db_session(db_engine):
connection = db_engine.connect() connection = db_engine.connect()
transaction = connection.begin() transaction = connection.begin()
session = Session(bind=connection) session = Session(bind=connection)
yield session yield session
# Rollback the transaction after each test # Rollback the transaction after each test
transaction.rollback() transaction.rollback()
connection.close() connection.close()
@ -52,12 +43,12 @@ def db_session(db_engine):
def test_create_user(db_session): def test_create_user(db_session):
"""Test creating a user in the database.""" """Test creating a user in the database."""
# Create user using CrudMixin methods # Create user using CrudMixin methods
user = TestUser(username="testuser", email="test@example.com") user = UserModel(username="testuser", email="test@example.com")
db_session.add(user) db_session.add(user)
db_session.commit() db_session.commit()
# Verify user was created # Verify user was created
db_user = db_session.query(TestUser).filter_by(username="testuser").first() db_user = db_session.query(UserModel).filter_by(username="testuser").first()
assert db_user is not None assert db_user is not None
assert db_user.email == "test@example.com" assert db_user.email == "test@example.com"
assert db_user.created_at is not None assert db_user.created_at is not None
@ -68,16 +59,16 @@ def test_create_user(db_session):
def test_update_user(db_session): def test_update_user(db_session):
"""Test updating a user in the database.""" """Test updating a user in the database."""
# Create user # Create user
user = TestUser(username="updateuser", email="update@example.com") user = UserModel(username="updateuser", email="update@example.com")
db_session.add(user) db_session.add(user)
db_session.commit() db_session.commit()
# Update user using CrudMixin methods # Update user using CrudMixin methods
user.update(session=db_session, email="newemail@example.com") user.update(db=db_session, email="newemail@example.com")
db_session.commit() db_session.commit()
# Verify update # Verify update
updated_user = db_session.query(TestUser).filter_by(username="updateuser").first() updated_user = db_session.query(UserModel).filter_by(username="updateuser").first()
assert updated_user.email == "newemail@example.com" assert updated_user.email == "newemail@example.com"
assert updated_user.updated_at is not None assert updated_user.updated_at is not None
@ -85,16 +76,16 @@ def test_update_user(db_session):
def test_soft_delete_user(db_session): def test_soft_delete_user(db_session):
"""Test soft deleting a user from the database.""" """Test soft deleting a user from the database."""
# Create user # Create user
user = TestUser(username="deleteuser", email="delete@example.com") user = UserModel(username="deleteuser", email="delete@example.com")
db_session.add(user) db_session.add(user)
db_session.commit() db_session.commit()
# Soft delete by updating deleted and active flags # Soft delete by updating deleted and active flags
user.update(session=db_session, deleted=True, active=False) user.update(db=db_session, deleted=True, active=False)
db_session.commit() db_session.commit()
# Verify soft deletion # Verify soft deletion
deleted_user = db_session.query(TestUser).filter_by(username="deleteuser").first() deleted_user = db_session.query(UserModel).filter_by(username="deleteuser").first()
assert deleted_user is not None assert deleted_user is not None
assert deleted_user.deleted assert deleted_user.deleted
assert not deleted_user.active assert not deleted_user.active
@ -105,4 +96,4 @@ def test_get_db_context_manager():
with get_db() as session: with get_db() as session:
# Verify we can execute a simple query # Verify we can execute a simple query
result = session.execute(text("SELECT 1")) result = session.execute(text("SELECT 1"))
assert result.scalar() == 1 assert result.scalar() == 1

View File

@ -0,0 +1,22 @@
services:
auth-service:
build:
context: .
dockerfile: DockerApiServices/AuthServiceApi/Dockerfile
ports:
- "41575:41575"
event-service:
build:
context: .
dockerfile: DockerApiServices/EventServiceApi/Dockerfile
ports:
- "8001:8000"
validation-service:
build:
context: .
dockerfile: DockerApiServices/ValidationServiceApi/Dockerfile
ports:
- "8002:8000"
# and lets try to implement potry again in the dockerfile now we now that it is about copy of files