Initial microservices setup from artifacts

This commit is contained in:
Urtzi Alfaro
2025-07-17 13:09:24 +02:00
commit 347ff51bd7
200 changed files with 9559 additions and 0 deletions

112
.gitignore vendored Normal file
View File

@@ -0,0 +1,112 @@
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.hypothesis/
.mypy_cache/
.dmyp.json
dmyp.json
.pyre/
# Virtual Environment
venv/
ENV/
env/
.venv
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.npm
.eslintcache
.next
out/
build/
dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
logs/
*.log
# Database
*.db
*.sqlite
*.sqlite3
# ML Models
*.pkl
*.joblib
*.h5
models/
# Data
data/external/
data/processed/
*.csv
*.xlsx
# Docker
.docker/
# Infrastructure
*.tfstate
*.tfstate.backup
.terraform/
.terraform.lock.hcl
# Kubernetes
kubeconfig
*.yaml.bak
# Monitoring
prometheus_data/
grafana_data/
elasticsearch_data/
# Artifacts (from Claude)
*_service.py
*_libraries.py
*.md
setup_scripts.sh

547
docker-compose.yml Normal file
View File

@@ -0,0 +1,547 @@
# docker-compose.yml - Development Environment
version: '3.8'
services:
# Message Broker
rabbitmq:
image: rabbitmq:3-management-alpine
container_name: bakery-rabbitmq
hostname: rabbitmq
ports:
- "5672:5672"
- "15672:15672"
environment:
- RABBITMQ_DEFAULT_USER=bakery
- RABBITMQ_DEFAULT_PASS=forecast123
- RABBITMQ_DEFAULT_VHOST=/
volumes:
- rabbitmq_data:/var/lib/rabbitmq
networks:
- bakery-network
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 30s
timeout: 10s
retries: 3
# Cache & Session Store
redis:
image: redis:7-alpine
container_name: bakery-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- bakery-network
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# Auth Service Database
auth-db:
image: postgres:15-alpine
container_name: bakery-auth-db
environment:
- POSTGRES_DB=auth_db
- POSTGRES_USER=auth_user
- POSTGRES_PASSWORD=auth_pass123
volumes:
- auth_db_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- bakery-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U auth_user -d auth_db"]
interval: 30s
timeout: 10s
retries: 3
# Training Service Database
training-db:
image: postgres:15-alpine
container_name: bakery-training-db
environment:
- POSTGRES_DB=training_db
- POSTGRES_USER=training_user
- POSTGRES_PASSWORD=training_pass123
volumes:
- training_db_data:/var/lib/postgresql/data
ports:
- "5433:5432"
networks:
- bakery-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U training_user -d training_db"]
interval: 30s
timeout: 10s
retries: 3
# Forecasting Service Database
forecasting-db:
image: postgres:15-alpine
container_name: bakery-forecasting-db
environment:
- POSTGRES_DB=forecasting_db
- POSTGRES_USER=forecasting_user
- POSTGRES_PASSWORD=forecasting_pass123
volumes:
- forecasting_db_data:/var/lib/postgresql/data
ports:
- "5434:5432"
networks:
- bakery-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U forecasting_user -d forecasting_db"]
interval: 30s
timeout: 10s
retries: 3
# Data Service Database
data-db:
image: postgres:15-alpine
container_name: bakery-data-db
environment:
- POSTGRES_DB=data_db
- POSTGRES_USER=data_user
- POSTGRES_PASSWORD=data_pass123
volumes:
- data_db_data:/var/lib/postgresql/data
ports:
- "5435:5432"
networks:
- bakery-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U data_user -d data_db"]
interval: 30s
timeout: 10s
retries: 3
# Tenant Service Database
tenant-db:
image: postgres:15-alpine
container_name: bakery-tenant-db
environment:
- POSTGRES_DB=tenant_db
- POSTGRES_USER=tenant_user
- POSTGRES_PASSWORD=tenant_pass123
volumes:
- tenant_db_data:/var/lib/postgresql/data
ports:
- "5436:5432"
networks:
- bakery-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tenant_user -d tenant_db"]
interval: 30s
timeout: 10s
retries: 3
# Notification Service Database
notification-db:
image: postgres:15-alpine
container_name: bakery-notification-db
environment:
- POSTGRES_DB=notification_db
- POSTGRES_USER=notification_user
- POSTGRES_PASSWORD=notification_pass123
volumes:
- notification_db_data:/var/lib/postgresql/data
ports:
- "5437:5432"
networks:
- bakery-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notification_user -d notification_db"]
interval: 30s
timeout: 10s
retries: 3
# Authentication Service
auth-service:
build:
context: ./services/auth
dockerfile: Dockerfile
container_name: bakery-auth-service
environment:
- DATABASE_URL=postgresql+asyncpg://auth_user:auth_pass123@auth-db:5432/auth_db
- REDIS_URL=redis://redis:6379/0
- JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production
- JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
- JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
- RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
- SERVICE_NAME=auth-service
- SERVICE_VERSION=1.0.0
ports:
- "8001:8000"
depends_on:
auth-db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
networks:
- bakery-network
volumes:
- ./services/auth:/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Training Service
training-service:
build:
context: ./services/training
dockerfile: Dockerfile
container_name: bakery-training-service
environment:
- DATABASE_URL=postgresql+asyncpg://training_user:training_pass123@training-db:5432/training_db
- REDIS_URL=redis://redis:6379/1
- RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
- AUTH_SERVICE_URL=http://auth-service:8000
- DATA_SERVICE_URL=http://data-service:8000
- SERVICE_NAME=training-service
- SERVICE_VERSION=1.0.0
ports:
- "8002:8000"
depends_on:
training-db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
auth-service:
condition: service_healthy
networks:
- bakery-network
volumes:
- ./services/training:/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Forecasting Service
forecasting-service:
build:
context: ./services/forecasting
dockerfile: Dockerfile
container_name: bakery-forecasting-service
environment:
- DATABASE_URL=postgresql+asyncpg://forecasting_user:forecasting_pass123@forecasting-db:5432/forecasting_db
- REDIS_URL=redis://redis:6379/2
- RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
- AUTH_SERVICE_URL=http://auth-service:8000
- TRAINING_SERVICE_URL=http://training-service:8000
- DATA_SERVICE_URL=http://data-service:8000
- SERVICE_NAME=forecasting-service
- SERVICE_VERSION=1.0.0
ports:
- "8003:8000"
depends_on:
forecasting-db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
auth-service:
condition: service_healthy
networks:
- bakery-network
volumes:
- ./services/forecasting:/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Data Service
data-service:
build:
context: ./services/data
dockerfile: Dockerfile
container_name: bakery-data-service
environment:
- DATABASE_URL=postgresql+asyncpg://data_user:data_pass123@data-db:5432/data_db
- REDIS_URL=redis://redis:6379/3
- RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
- AUTH_SERVICE_URL=http://auth-service:8000
- AEMET_API_KEY=your-aemet-api-key-here
- MADRID_OPENDATA_API_KEY=your-madrid-opendata-key-here
- SERVICE_NAME=data-service
- SERVICE_VERSION=1.0.0
ports:
- "8004:8000"
depends_on:
data-db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
auth-service:
condition: service_healthy
networks:
- bakery-network
volumes:
- ./services/data:/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Tenant Service
tenant-service:
build:
context: ./services/tenant
dockerfile: Dockerfile
container_name: bakery-tenant-service
environment:
- DATABASE_URL=postgresql+asyncpg://tenant_user:tenant_pass123@tenant-db:5432/tenant_db
- REDIS_URL=redis://redis:6379/4
- RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
- AUTH_SERVICE_URL=http://auth-service:8000
- SERVICE_NAME=tenant-service
- SERVICE_VERSION=1.0.0
ports:
- "8005:8000"
depends_on:
tenant-db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
auth-service:
condition: service_healthy
networks:
- bakery-network
volumes:
- ./services/tenant:/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Notification Service
notification-service:
build:
context: ./services/notification
dockerfile: Dockerfile
container_name: bakery-notification-service
environment:
- DATABASE_URL=postgresql+asyncpg://notification_user:notification_pass123@notification-db:5432/notification_db
- REDIS_URL=redis://redis:6379/5
- RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
- AUTH_SERVICE_URL=http://auth-service:8000
- SMTP_HOST=smtp.gmail.com
- SMTP_PORT=587
- SMTP_USER=your-email@gmail.com
- SMTP_PASSWORD=your-email-password
- WHATSAPP_API_KEY=your-whatsapp-api-key
- SERVICE_NAME=notification-service
- SERVICE_VERSION=1.0.0
ports:
- "8006:8000"
depends_on:
notification-db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
auth-service:
condition: service_healthy
networks:
- bakery-network
volumes:
- ./services/notification:/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# API Gateway
gateway:
build:
context: ./gateway
dockerfile: Dockerfile
container_name: bakery-gateway
environment:
- REDIS_URL=redis://redis:6379/6
- AUTH_SERVICE_URL=http://auth-service:8000
- TRAINING_SERVICE_URL=http://training-service:8000
- FORECASTING_SERVICE_URL=http://forecasting-service:8000
- DATA_SERVICE_URL=http://data-service:8000
- TENANT_SERVICE_URL=http://tenant-service:8000
- NOTIFICATION_SERVICE_URL=http://notification-service:8000
- CORS_ORIGINS=http://localhost:3000,http://localhost:3001
- SERVICE_NAME=gateway
- SERVICE_VERSION=1.0.0
ports:
- "8000:8000"
depends_on:
auth-service:
condition: service_healthy
training-service:
condition: service_healthy
forecasting-service:
condition: service_healthy
data-service:
condition: service_healthy
tenant-service:
condition: service_healthy
notification-service:
condition: service_healthy
networks:
- bakery-network
volumes:
- ./gateway:/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Dashboard Frontend
dashboard:
build:
context: ./frontend/dashboard
dockerfile: Dockerfile
container_name: bakery-dashboard
environment:
- REACT_APP_API_URL=http://localhost:8000
- REACT_APP_WS_URL=ws://localhost:8000
- CHOKIDAR_USEPOLLING=true
ports:
- "3000:3000"
depends_on:
- gateway
networks:
- bakery-network
volumes:
- ./frontend/dashboard:/app
- /app/node_modules
# Marketing Site
marketing:
build:
context: ./frontend/marketing
dockerfile: Dockerfile
container_name: bakery-marketing
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000
- NEXT_PUBLIC_SITE_URL=http://localhost:3001
ports:
- "3001:3000"
depends_on:
- gateway
networks:
- bakery-network
volumes:
- ./frontend/marketing:/app
- /app/node_modules
# Monitoring - Prometheus
prometheus:
image: prom/prometheus:latest
container_name: bakery-prometheus
ports:
- "9090:9090"
volumes:
- ./infrastructure/monitoring/prometheus:/etc/prometheus
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
networks:
- bakery-network
# Monitoring - Grafana
grafana:
image: grafana/grafana:latest
container_name: bakery-grafana
ports:
- "3002:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin123
volumes:
- grafana_data:/var/lib/grafana
- ./infrastructure/monitoring/grafana:/etc/grafana/provisioning
depends_on:
- prometheus
networks:
- bakery-network
# Log Aggregation - ELK Stack
elasticsearch:
image: elasticsearch:8.8.0
container_name: bakery-elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ports:
- "9200:9200"
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data
networks:
- bakery-network
logstash:
image: logstash:8.8.0
container_name: bakery-logstash
volumes:
- ./infrastructure/monitoring/logstash:/usr/share/logstash/pipeline
ports:
- "5000:5000"
depends_on:
- elasticsearch
networks:
- bakery-network
kibana:
image: kibana:8.8.0
container_name: bakery-kibana
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
ports:
- "5601:5601"
depends_on:
- elasticsearch
networks:
- bakery-network
volumes:
rabbitmq_data:
redis_data:
auth_db_data:
training_db_data:
forecasting_db_data:
data_db_data:
tenant_db_data:
notification_db_data:
prometheus_data:
grafana_data:
elasticsearch_data:
networks:
bakery-network:
driver: bridge

0
gateway/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,52 @@
"""
Gateway configuration
"""
import os
from typing import List, Dict
from pydantic import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
# Basic settings
APP_NAME: str = "Bakery Forecasting Gateway"
VERSION: str = "1.0.0"
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# CORS settings
CORS_ORIGINS: List[str] = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:3001").split(",")
# Service URLs
AUTH_SERVICE_URL: str = os.getenv("AUTH_SERVICE_URL", "http://auth-service:8000")
TRAINING_SERVICE_URL: str = os.getenv("TRAINING_SERVICE_URL", "http://training-service:8000")
FORECASTING_SERVICE_URL: str = os.getenv("FORECASTING_SERVICE_URL", "http://forecasting-service:8000")
DATA_SERVICE_URL: str = os.getenv("DATA_SERVICE_URL", "http://data-service:8000")
TENANT_SERVICE_URL: str = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000")
NOTIFICATION_SERVICE_URL: str = os.getenv("NOTIFICATION_SERVICE_URL", "http://notification-service:8000")
# Redis settings
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/6")
# Rate limiting
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
# JWT settings
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
@property
def SERVICES(self) -> Dict[str, str]:
"""Service registry"""
return {
"auth": self.AUTH_SERVICE_URL,
"training": self.TRAINING_SERVICE_URL,
"forecasting": self.FORECASTING_SERVICE_URL,
"data": self.DATA_SERVICE_URL,
"tenant": self.TENANT_SERVICE_URL,
"notification": self.NOTIFICATION_SERVICE_URL
}
settings = Settings()

View File

@@ -0,0 +1,122 @@
"""
Service discovery for microservices
"""
import asyncio
import logging
from typing import Dict, List, Optional
import httpx
import redis.asyncio as redis
from datetime import datetime, timedelta
from app.core.config import settings
logger = logging.getLogger(__name__)
class ServiceDiscovery:
"""Service discovery and health checking"""
def __init__(self):
self.redis_client = redis.from_url(settings.REDIS_URL)
self.services = settings.SERVICES
self.health_check_interval = 30 # seconds
self.health_check_task = None
async def initialize(self):
"""Initialize service discovery"""
logger.info("Initializing service discovery")
# Start health check task
self.health_check_task = asyncio.create_task(self._health_check_loop())
# Initial health check
await self._check_all_services()
async def cleanup(self):
"""Cleanup service discovery"""
if self.health_check_task:
self.health_check_task.cancel()
try:
await self.health_check_task
except asyncio.CancelledError:
pass
await self.redis_client.close()
async def get_service_url(self, service_name: str) -> Optional[str]:
"""Get service URL"""
return self.services.get(service_name)
async def get_healthy_services(self) -> List[str]:
"""Get list of healthy services"""
healthy_services = []
for service_name in self.services:
is_healthy = await self._is_service_healthy(service_name)
if is_healthy:
healthy_services.append(service_name)
return healthy_services
async def _health_check_loop(self):
"""Continuous health check loop"""
while True:
try:
await self._check_all_services()
await asyncio.sleep(self.health_check_interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Health check error: {e}")
await asyncio.sleep(self.health_check_interval)
async def _check_all_services(self):
"""Check health of all services"""
for service_name, service_url in self.services.items():
try:
is_healthy = await self._check_service_health(service_url)
await self._update_service_health(service_name, is_healthy)
except Exception as e:
logger.error(f"Health check failed for {service_name}: {e}")
await self._update_service_health(service_name, False)
async def _check_service_health(self, service_url: str) -> bool:
"""Check individual service health"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{service_url}/health")
return response.status_code == 200
except Exception as e:
logger.warning(f"Service health check failed: {e}")
return False
async def _update_service_health(self, service_name: str, is_healthy: bool):
"""Update service health status in Redis"""
try:
key = f"service_health:{service_name}"
value = {
"healthy": is_healthy,
"last_check": datetime.utcnow().isoformat(),
"url": self.services[service_name]
}
await self.redis_client.hset(key, mapping=value)
await self.redis_client.expire(key, 300) # 5 minutes TTL
except Exception as e:
logger.error(f"Failed to update service health for {service_name}: {e}")
async def _is_service_healthy(self, service_name: str) -> bool:
"""Check if service is healthy from Redis cache"""
try:
key = f"service_health:{service_name}"
health_data = await self.redis_client.hgetall(key)
if not health_data:
return False
return health_data.get(b'healthy', b'false').decode() == 'True'
except Exception as e:
logger.error(f"Failed to check service health for {service_name}: {e}")
return False

131
gateway/app/main.py Normal file
View File

@@ -0,0 +1,131 @@
"""
API Gateway - Central entry point for all microservices
Handles routing, authentication, rate limiting, and cross-cutting concerns
"""
import asyncio
import logging
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import httpx
import time
from typing import Dict, Any
from app.core.config import settings
from app.core.service_discovery import ServiceDiscovery
from app.middleware.auth import auth_middleware
from app.middleware.logging import logging_middleware
from app.middleware.rate_limit import rate_limit_middleware
from app.routes import auth, training, forecasting, data, tenant, notification
from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector
# Setup logging
setup_logging("gateway", settings.LOG_LEVEL)
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="Bakery Forecasting API Gateway",
description="Central API Gateway for bakery forecasting microservices",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# Initialize metrics collector
metrics_collector = MetricsCollector("gateway")
# Service discovery
service_discovery = ServiceDiscovery()
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Custom middleware
app.add_middleware(auth_middleware)
app.add_middleware(logging_middleware)
app.add_middleware(rate_limit_middleware)
# Include routers
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
app.include_router(training.router, prefix="/api/v1/training", tags=["training"])
app.include_router(forecasting.router, prefix="/api/v1/forecasting", tags=["forecasting"])
app.include_router(data.router, prefix="/api/v1/data", tags=["data"])
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
@app.on_event("startup")
async def startup_event():
"""Application startup"""
logger.info("Starting API Gateway")
# Start metrics server
metrics_collector.start_metrics_server(8080)
# Initialize service discovery
await service_discovery.initialize()
logger.info("API Gateway started successfully")
@app.on_event("shutdown")
async def shutdown_event():
"""Application shutdown"""
logger.info("Shutting down API Gateway")
# Clean up service discovery
await service_discovery.cleanup()
logger.info("API Gateway shutdown complete")
@app.get("/health")
async def health_check():
"""Health check endpoint"""
healthy_services = await service_discovery.get_healthy_services()
return {
"status": "healthy",
"service": "gateway",
"version": "1.0.0",
"healthy_services": healthy_services,
"total_services": len(settings.SERVICES),
"timestamp": time.time()
}
@app.get("/metrics")
async def get_metrics():
"""Get basic metrics"""
return {
"service": "gateway",
"uptime": time.time() - app.state.start_time if hasattr(app.state, 'start_time') else 0,
"healthy_services": await service_discovery.get_healthy_services()
}
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""Handle HTTP exceptions"""
logger.error(f"HTTP {exc.status_code}: {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail, "service": "gateway"}
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Handle general exceptions"""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "service": "gateway"}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

View File

@@ -0,0 +1,101 @@
"""
Authentication middleware for gateway
"""
import logging
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
import httpx
from typing import Optional
from app.core.config import settings
from shared.auth.jwt_handler import JWTHandler
logger = logging.getLogger(__name__)
# JWT handler
jwt_handler = JWTHandler(settings.JWT_SECRET_KEY, settings.JWT_ALGORITHM)
# Routes that don't require authentication
PUBLIC_ROUTES = [
"/health",
"/metrics",
"/docs",
"/redoc",
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/refresh"
]
async def auth_middleware(request: Request, call_next):
"""Authentication middleware"""
# Check if route requires authentication
if _is_public_route(request.url.path):
return await call_next(request)
# Get token from header
token = _extract_token(request)
if not token:
return JSONResponse(
status_code=401,
content={"detail": "Authentication required"}
)
# Verify token
try:
# First try to verify token locally
payload = jwt_handler.verify_token(token)
if payload:
# Add user info to request state
request.state.user = payload
return await call_next(request)
else:
# Token invalid or expired, verify with auth service
user_info = await _verify_with_auth_service(token)
if user_info:
request.state.user = user_info
return await call_next(request)
else:
return JSONResponse(
status_code=401,
content={"detail": "Invalid or expired token"}
)
except Exception as e:
logger.error(f"Authentication error: {e}")
return JSONResponse(
status_code=401,
content={"detail": "Authentication failed"}
)
def _is_public_route(path: str) -> bool:
"""Check if route is public"""
return any(path.startswith(route) for route in PUBLIC_ROUTES)
def _extract_token(request: Request) -> Optional[str]:
"""Extract JWT token from request"""
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
return auth_header.split(" ")[1]
return None
async def _verify_with_auth_service(token: str) -> Optional[dict]:
"""Verify token with auth service"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/verify",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 200:
return response.json()
else:
return None
except Exception as e:
logger.error(f"Auth service verification failed: {e}")
return None

View File

@@ -0,0 +1,48 @@
"""
Logging middleware for gateway
"""
import logging
import time
from fastapi import Request
import json
logger = logging.getLogger(__name__)
async def logging_middleware(request: Request, call_next):
"""Logging middleware"""
start_time = time.time()
# Log request
logger.info(
f"Request: {request.method} {request.url.path}",
extra={
"method": request.method,
"url": request.url.path,
"query_params": str(request.query_params),
"client_host": request.client.host,
"user_agent": request.headers.get("user-agent", ""),
"request_id": getattr(request.state, 'request_id', None)
}
)
# Process request
response = await call_next(request)
# Calculate duration
duration = time.time() - start_time
# Log response
logger.info(
f"Response: {response.status_code} in {duration:.3f}s",
extra={
"status_code": response.status_code,
"duration": duration,
"method": request.method,
"url": request.url.path,
"request_id": getattr(request.state, 'request_id', None)
}
)
return response

View File

@@ -0,0 +1,85 @@
"""
Rate limiting middleware for gateway
"""
import logging
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
import redis.asyncio as redis
from datetime import datetime, timedelta
import hashlib
from app.core.config import settings
logger = logging.getLogger(__name__)
# Redis client for rate limiting
redis_client = redis.from_url(settings.REDIS_URL)
async def rate_limit_middleware(request: Request, call_next):
"""Rate limiting middleware"""
# Skip rate limiting for health checks
if request.url.path in ["/health", "/metrics"]:
return await call_next(request)
# Get client identifier (IP address or user ID)
client_id = _get_client_id(request)
# Check rate limit
if await _is_rate_limited(client_id):
return JSONResponse(
status_code=429,
content={
"detail": "Rate limit exceeded",
"retry_after": settings.RATE_LIMIT_WINDOW
}
)
# Process request
response = await call_next(request)
# Update rate limit counter
await _update_rate_limit(client_id)
return response
def _get_client_id(request: Request) -> str:
"""Get client identifier for rate limiting"""
# Use user ID if authenticated, otherwise use IP
if hasattr(request.state, 'user') and request.state.user:
return f"user:{request.state.user.get('user_id', 'unknown')}"
else:
# Hash IP address for privacy
ip = request.client.host
return f"ip:{hashlib.md5(ip.encode()).hexdigest()}"
async def _is_rate_limited(client_id: str) -> bool:
"""Check if client is rate limited"""
try:
key = f"rate_limit:{client_id}"
current_count = await redis_client.get(key)
if current_count is None:
return False
return int(current_count) >= settings.RATE_LIMIT_REQUESTS
except Exception as e:
logger.error(f"Rate limit check failed: {e}")
return False
async def _update_rate_limit(client_id: str):
"""Update rate limit counter"""
try:
key = f"rate_limit:{client_id}"
# Increment counter
current_count = await redis_client.incr(key)
# Set TTL on first request
if current_count == 1:
await redis_client.expire(key, settings.RATE_LIMIT_WINDOW)
except Exception as e:
logger.error(f"Rate limit update failed: {e}")

View File

161
gateway/app/routes/auth.py Normal file
View File

@@ -0,0 +1,161 @@
"""
Authentication routes for gateway
"""
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
import httpx
import logging
from app.core.config import settings
from app.core.service_discovery import ServiceDiscovery
logger = logging.getLogger(__name__)
router = APIRouter()
service_discovery = ServiceDiscovery()
@router.post("/login")
async def login(request: Request):
"""Proxy login request to auth service"""
try:
body = await request.body()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/login",
content=body,
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
return response.json()
else:
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Login error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/register")
async def register(request: Request):
"""Proxy register request to auth service"""
try:
body = await request.body()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/register",
content=body,
headers={"Content-Type": "application/json"}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Register error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/refresh")
async def refresh_token(request: Request):
"""Proxy refresh token request to auth service"""
try:
body = await request.body()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/refresh",
content=body,
headers={"Content-Type": "application/json"}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Refresh token error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/verify")
async def verify_token(request: Request):
"""Proxy token verification to auth service"""
try:
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(status_code=401, detail="Authorization header required")
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/verify",
headers={"Authorization": auth_header}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Token verification error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/logout")
async def logout(request: Request):
"""Proxy logout request to auth service"""
try:
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(status_code=401, detail="Authorization header required")
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"{settings.AUTH_SERVICE_URL}/logout",
headers={"Authorization": auth_header}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Logout error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1,166 @@
"""
Training routes for gateway
"""
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import JSONResponse
import httpx
import logging
from typing import Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/train")
async def start_training(request: Request):
"""Proxy training request to training service"""
try:
body = await request.body()
auth_header = request.headers.get("Authorization")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{settings.TRAINING_SERVICE_URL}/train",
content=body,
headers={
"Content-Type": "application/json",
"Authorization": auth_header
}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Training service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Training service unavailable"
)
except Exception as e:
logger.error(f"Training error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/status/{training_job_id}")
async def get_training_status(training_job_id: str, request: Request):
"""Get training job status"""
try:
auth_header = request.headers.get("Authorization")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{settings.TRAINING_SERVICE_URL}/status/{training_job_id}",
headers={"Authorization": auth_header}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Training service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Training service unavailable"
)
except Exception as e:
logger.error(f"Training status error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/models")
async def get_trained_models(request: Request):
"""Get trained models"""
try:
auth_header = request.headers.get("Authorization")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{settings.TRAINING_SERVICE_URL}/models",
headers={"Authorization": auth_header}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Training service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Training service unavailable"
)
except Exception as e:
logger.error(f"Get models error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/jobs")
async def get_training_jobs(
request: Request,
limit: Optional[int] = Query(10, ge=1, le=100),
offset: Optional[int] = Query(0, ge=0)
):
"""Get training jobs"""
try:
auth_header = request.headers.get("Authorization")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{settings.TRAINING_SERVICE_URL}/jobs",
params={"limit": limit, "offset": offset},
headers={"Authorization": auth_header}
)
return JSONResponse(
status_code=response.status_code,
content=response.json()
)
except httpx.RequestError as e:
logger.error(f"Training service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Training service unavailable"
)
except Exception as e:
logger.error(f"Get training jobs error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
# gateway/Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

13
gateway/requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
httpx==0.25.2
redis==5.0.1
pydantic==2.5.0
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
python-multipart==0.0.6
prometheus-client==0.17.1
python-json-logger==2.0.4
email-validator==2.0.0
aio-pika==9.3.0
pytz==2023.3

View File

View File

@@ -0,0 +1,41 @@
"""
Authentication decorators for FastAPI
"""
from functools import wraps
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer
import httpx
import logging
logger = logging.getLogger(__name__)
security = HTTPBearer()
def verify_service_token(auth_service_url: str):
"""Verify service token with auth service"""
async def verify_token(token: str = Depends(security)):
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{auth_service_url}/verify",
headers={"Authorization": f"Bearer {token.credentials}"}
)
if response.status_code == 200:
return response.json()
else:
raise HTTPException(
status_code=401,
detail="Invalid authentication credentials"
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
return verify_token

View File

@@ -0,0 +1,58 @@
"""
Shared JWT Authentication Handler
Used across all microservices for consistent authentication
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
class JWTHandler:
"""JWT token handling for microservices"""
def __init__(self, secret_key: str, algorithm: str = "HS256"):
self.secret_key = secret_key
self.algorithm = algorithm
def create_access_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=30)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def create_refresh_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT refresh token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=7)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify and decode JWT token"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
return payload
except jwt.ExpiredSignatureError:
logger.warning("Token has expired")
return None
except jwt.InvalidTokenError:
logger.warning("Invalid token")
return None

View File

View File

@@ -0,0 +1,56 @@
"""
Base database configuration for all microservices
"""
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.pool import StaticPool
import logging
logger = logging.getLogger(__name__)
Base = declarative_base()
class DatabaseManager:
"""Database manager for microservices"""
def __init__(self, database_url: str):
self.database_url = database_url
self.async_engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_recycle=300,
pool_size=20,
max_overflow=30
)
self.async_session_local = sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db(self):
"""Get database session"""
async with self.async_session_local() as session:
try:
yield session
except Exception as e:
logger.error(f"Database session error: {e}")
await session.rollback()
raise
finally:
await session.close()
async def create_tables(self):
"""Create database tables"""
async with self.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_tables(self):
"""Drop database tables"""
async with self.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)

View File

View File

@@ -0,0 +1,73 @@
"""
Event definitions for microservices communication
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Any, Optional
import uuid
@dataclass
class BaseEvent:
"""Base event class"""
event_id: str
event_type: str
service_name: str
timestamp: datetime
data: Dict[str, Any]
correlation_id: Optional[str] = None
def __post_init__(self):
if not self.event_id:
self.event_id = str(uuid.uuid4())
if not self.timestamp:
self.timestamp = datetime.utcnow()
# Training Events
@dataclass
class TrainingStartedEvent(BaseEvent):
event_type: str = "training.started"
@dataclass
class TrainingCompletedEvent(BaseEvent):
event_type: str = "training.completed"
@dataclass
class TrainingFailedEvent(BaseEvent):
event_type: str = "training.failed"
# Forecasting Events
@dataclass
class ForecastGeneratedEvent(BaseEvent):
event_type: str = "forecast.generated"
@dataclass
class ForecastRequestedEvent(BaseEvent):
event_type: str = "forecast.requested"
# User Events
@dataclass
class UserRegisteredEvent(BaseEvent):
event_type: str = "user.registered"
@dataclass
class UserLoginEvent(BaseEvent):
event_type: str = "user.login"
# Tenant Events
@dataclass
class TenantCreatedEvent(BaseEvent):
event_type: str = "tenant.created"
@dataclass
class TenantUpdatedEvent(BaseEvent):
event_type: str = "tenant.updated"
# Notification Events
@dataclass
class NotificationSentEvent(BaseEvent):
event_type: str = "notification.sent"
@dataclass
class NotificationFailedEvent(BaseEvent):
event_type: str = "notification.failed"

View File

@@ -0,0 +1,96 @@
"""
RabbitMQ messaging client for microservices
"""
import asyncio
import json
import logging
from typing import Dict, Any, Callable
import aio_pika
from aio_pika import connect_robust, Message, DeliveryMode
logger = logging.getLogger(__name__)
class RabbitMQClient:
"""RabbitMQ client for microservices communication"""
def __init__(self, connection_url: str):
self.connection_url = connection_url
self.connection = None
self.channel = None
async def connect(self):
"""Connect to RabbitMQ"""
try:
self.connection = await connect_robust(self.connection_url)
self.channel = await self.connection.channel()
logger.info("Connected to RabbitMQ")
except Exception as e:
logger.error(f"Failed to connect to RabbitMQ: {e}")
raise
async def disconnect(self):
"""Disconnect from RabbitMQ"""
if self.connection:
await self.connection.close()
logger.info("Disconnected from RabbitMQ")
async def publish_event(self, exchange_name: str, routing_key: str, event_data: Dict[str, Any]):
"""Publish event to RabbitMQ"""
try:
if not self.channel:
await self.connect()
# Declare exchange
exchange = await self.channel.declare_exchange(
exchange_name,
aio_pika.ExchangeType.TOPIC,
durable=True
)
# Create message
message = Message(
json.dumps(event_data).encode(),
delivery_mode=DeliveryMode.PERSISTENT,
content_type="application/json"
)
# Publish message
await exchange.publish(message, routing_key=routing_key)
logger.info(f"Published event to {exchange_name} with routing key {routing_key}")
except Exception as e:
logger.error(f"Failed to publish event: {e}")
raise
async def consume_events(self, exchange_name: str, queue_name: str, routing_key: str, callback: Callable):
"""Consume events from RabbitMQ"""
try:
if not self.channel:
await self.connect()
# Declare exchange
exchange = await self.channel.declare_exchange(
exchange_name,
aio_pika.ExchangeType.TOPIC,
durable=True
)
# Declare queue
queue = await self.channel.declare_queue(
queue_name,
durable=True
)
# Bind queue to exchange
await queue.bind(exchange, routing_key)
# Set up consumer
await queue.consume(callback)
logger.info(f"Started consuming events from {queue_name}")
except Exception as e:
logger.error(f"Failed to consume events: {e}")
raise

View File

View File

@@ -0,0 +1,77 @@
"""
Centralized logging configuration for microservices
"""
import logging
import logging.config
import os
from typing import Dict, Any
def setup_logging(service_name: str, log_level: str = "INFO") -> None:
"""Set up logging configuration for a microservice"""
config: Dict[str, Any] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
},
"detailed": {
"format": "%(asctime)s [%(levelname)s] %(name)s [%(filename)s:%(lineno)d] %(message)s"
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": log_level,
"formatter": "standard",
"stream": "ext://sys.stdout"
},
"file": {
"class": "logging.FileHandler",
"level": log_level,
"formatter": "detailed",
"filename": f"/var/log/{service_name}.log",
"mode": "a"
},
"logstash": {
"class": "logstash.TCPLogstashHandler",
"host": os.getenv("LOGSTASH_HOST", "localhost"),
"port": int(os.getenv("LOGSTASH_PORT", "5000")),
"version": 1,
"message_type": "logstash",
"fqdn": False,
"tags": [service_name]
}
},
"loggers": {
"": {
"handlers": ["console", "file"],
"level": log_level,
"propagate": False
},
"uvicorn": {
"handlers": ["console"],
"level": log_level,
"propagate": False
},
"uvicorn.access": {
"handlers": ["console"],
"level": log_level,
"propagate": False
}
}
}
# Add logstash handler if in production
if os.getenv("ENVIRONMENT") == "production":
config["loggers"][""]["handlers"].append("logstash")
logging.config.dictConfig(config)
logger = logging.getLogger(__name__)
logger.info(f"Logging configured for {service_name}")

View File

@@ -0,0 +1,112 @@
"""
Metrics collection for microservices
"""
import time
import logging
from typing import Dict, Any
from prometheus_client import Counter, Histogram, Gauge, start_http_server
from functools import wraps
logger = logging.getLogger(__name__)
# Prometheus metrics
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status_code', 'service']
)
REQUEST_DURATION = Histogram(
'http_request_duration_seconds',
'HTTP request duration in seconds',
['method', 'endpoint', 'service']
)
ACTIVE_CONNECTIONS = Gauge(
'active_connections',
'Active database connections',
['service']
)
TRAINING_JOBS = Counter(
'training_jobs_total',
'Total training jobs',
['status', 'service']
)
FORECASTS_GENERATED = Counter(
'forecasts_generated_total',
'Total forecasts generated',
['service']
)
class MetricsCollector:
"""Metrics collector for microservices"""
def __init__(self, service_name: str):
self.service_name = service_name
self.start_time = time.time()
def start_metrics_server(self, port: int = 8080):
"""Start Prometheus metrics server"""
try:
start_http_server(port)
logger.info(f"Metrics server started on port {port}")
except Exception as e:
logger.error(f"Failed to start metrics server: {e}")
def record_request(self, method: str, endpoint: str, status_code: int, duration: float):
"""Record HTTP request metrics"""
REQUEST_COUNT.labels(
method=method,
endpoint=endpoint,
status_code=status_code,
service=self.service_name
).inc()
REQUEST_DURATION.labels(
method=method,
endpoint=endpoint,
service=self.service_name
).observe(duration)
def record_training_job(self, status: str):
"""Record training job metrics"""
TRAINING_JOBS.labels(
status=status,
service=self.service_name
).inc()
def record_forecast_generated(self):
"""Record forecast generation metrics"""
FORECASTS_GENERATED.labels(
service=self.service_name
).inc()
def set_active_connections(self, count: int):
"""Set active database connections"""
ACTIVE_CONNECTIONS.labels(
service=self.service_name
).set(count)
def metrics_middleware(metrics_collector: MetricsCollector):
"""Middleware to collect metrics"""
def middleware(request, call_next):
start_time = time.time()
response = call_next(request)
duration = time.time() - start_time
metrics_collector.record_request(
method=request.method,
endpoint=request.url.path,
status_code=response.status_code,
duration=duration
)
return response
return middleware

View File

View File

@@ -0,0 +1,71 @@
"""
DateTime utilities for microservices
"""
from datetime import datetime, timezone, timedelta
from typing import Optional
import pytz
def utc_now() -> datetime:
"""Get current UTC datetime"""
return datetime.now(timezone.utc)
def madrid_now() -> datetime:
"""Get current Madrid datetime"""
madrid_tz = pytz.timezone('Europe/Madrid')
return datetime.now(madrid_tz)
def to_utc(dt: datetime) -> datetime:
"""Convert datetime to UTC"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_madrid(dt: datetime) -> datetime:
"""Convert datetime to Madrid timezone"""
madrid_tz = pytz.timezone('Europe/Madrid')
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(madrid_tz)
def format_datetime(dt: datetime, format_str: str = "%Y-%m-%d %H:%M:%S") -> str:
"""Format datetime as string"""
return dt.strftime(format_str)
def parse_datetime(dt_str: str, format_str: str = "%Y-%m-%d %H:%M:%S") -> datetime:
"""Parse datetime from string"""
return datetime.strptime(dt_str, format_str)
def is_business_hours(dt: Optional[datetime] = None) -> bool:
"""Check if datetime is during business hours (9 AM - 6 PM Madrid time)"""
if dt is None:
dt = madrid_now()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
madrid_dt = to_madrid(dt)
# Check if it's a weekday (Monday=0, Sunday=6)
if madrid_dt.weekday() >= 5: # Weekend
return False
# Check if it's business hours
return 9 <= madrid_dt.hour < 18
def next_business_day(dt: Optional[datetime] = None) -> datetime:
"""Get next business day"""
if dt is None:
dt = madrid_now()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
madrid_dt = to_madrid(dt)
# Add days until we reach a weekday
while madrid_dt.weekday() >= 5: # Weekend
madrid_dt += timedelta(days=1)
# Set to 9 AM
return madrid_dt.replace(hour=9, minute=0, second=0, microsecond=0)

View File

@@ -0,0 +1,67 @@
"""
Validation utilities for microservices
"""
import re
from typing import Any, Optional
from email_validator import validate_email, EmailNotValidError
def validate_spanish_phone(phone: str) -> bool:
"""Validate Spanish phone number"""
# Spanish phone pattern: +34 followed by 9 digits
pattern = r'^(\+34|0034|34)?[6-9]\d{8}$'
return bool(re.match(pattern, phone.replace(' ', '').replace('-', '')))
def validate_email_address(email: str) -> bool:
"""Validate email address"""
try:
validate_email(email)
return True
except EmailNotValidError:
return False
def validate_tenant_name(name: str) -> bool:
"""Validate tenant name"""
# Must be 2-50 characters, letters, numbers, spaces, hyphens, apostrophes
pattern = r"^[a-zA-ZÀ-ÿ0-9\s\-']{2,50}$"
return bool(re.match(pattern, name))
def validate_address(address: str) -> bool:
"""Validate address"""
# Must be 5-200 characters
return 5 <= len(address.strip()) <= 200
def validate_coordinates(latitude: float, longitude: float) -> bool:
"""Validate Madrid coordinates"""
# Madrid is roughly between these coordinates
madrid_bounds = {
'lat_min': 40.3,
'lat_max': 40.6,
'lon_min': -3.8,
'lon_max': -3.5
}
return (
madrid_bounds['lat_min'] <= latitude <= madrid_bounds['lat_max'] and
madrid_bounds['lon_min'] <= longitude <= madrid_bounds['lon_max']
)
def validate_product_name(name: str) -> bool:
"""Validate product name"""
# Must be 1-50 characters, letters, numbers, spaces
pattern = r"^[a-zA-ZÀ-ÿ0-9\s]{1,50}$"
return bool(re.match(pattern, name))
def validate_positive_number(value: Any) -> bool:
"""Validate positive number"""
try:
return float(value) > 0
except (ValueError, TypeError):
return False
def validate_non_negative_number(value: Any) -> bool:
"""Validate non-negative number"""
try:
return float(value) >= 0
except (ValueError, TypeError):
return False

View File

@@ -0,0 +1,31 @@
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'gateway'
static_configs:
- targets: ['gateway:8080']
- job_name: 'auth-service'
static_configs:
- targets: ['auth-service:8080']
- job_name: 'training-service'
static_configs:
- targets: ['training-service:8080']
- job_name: 'forecasting-service'
static_configs:
- targets: ['forecasting-service:8080']
- job_name: 'data-service'
static_configs:
- targets: ['data-service:8080']
- job_name: 'tenant-service'
static_configs:
- targets: ['tenant-service:8080']
- job_name: 'notification-service'
static_configs:
- targets: ['notification-service:8080']

18
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
echo "🚀 Deploying Bakery Forecasting Platform..."
# Build and deploy all services
docker-compose build
docker-compose up -d
echo "Waiting for services to be healthy..."
sleep 30
# Check service health
echo "Checking service health..."
curl -f http://localhost:8000/health || echo "Gateway health check failed"
echo "✅ Deployment completed"
echo "Gateway: http://localhost:8000"
echo "API Docs: http://localhost:8000/docs"

879
scripts/setup.sh Executable file
View File

@@ -0,0 +1,879 @@
#!/bin/bash
# scripts/setup.sh
# Intelligent Setup Script - Extract artifacts and create microservices structure
set -e
echo "🚀 Setting up Bakery Forecasting Microservices Platform"
echo "========================================================"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_step() {
echo -e "${BLUE}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_warning() {
echo -e "${YELLOW}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
# Check prerequisites
print_step "Checking prerequisites..."
command -v docker >/dev/null 2>&1 || {
print_error "Docker is required but not installed. Please install Docker first."
exit 1
}
command -v docker-compose >/dev/null 2>&1 || {
print_error "Docker Compose is required but not installed. Please install Docker Compose first."
exit 1
}
print_success "Prerequisites check passed"
# Function to extract files from artifact files
extract_artifact_files() {
local artifact_file="$1"
local description="$2"
print_step "Processing $description..."
if [ ! -f "$artifact_file" ]; then
print_warning "Artifact file $artifact_file not found, skipping..."
return
fi
# Read the artifact file and extract individual files
local current_file=""
local current_content=""
local in_file=false
while IFS= read -r line; do
# Check if line starts with a file path (contains .py, .yml, .md, .sh, etc.)
if [[ "$line" =~ ^#[[:space:]]*(.*\.(py|yml|yaml|md|sh|txt|js|json|html|css|Dockerfile|requirements\.txt))$ ]]; then
# Save previous file if we were processing one
if [ "$in_file" = true ] && [ -n "$current_file" ]; then
# Create directory if it doesn't exist
local dir=$(dirname "$current_file")
mkdir -p "$dir"
# Write content to file
echo "$current_content" > "$current_file"
print_success "Created: $current_file"
fi
# Start new file
current_file=$(echo "$line" | sed 's/^#[[:space:]]*//')
current_content=""
in_file=true
elif [ "$in_file" = true ]; then
# Add line to current file content
if [ -n "$current_content" ]; then
current_content="$current_content\n$line"
else
current_content="$line"
fi
fi
done < "$artifact_file"
# Save the last file
if [ "$in_file" = true ] && [ -n "$current_file" ]; then
local dir=$(dirname "$current_file")
mkdir -p "$dir"
echo -e "$current_content" > "$current_file"
print_success "Created: $current_file"
fi
}
# Function to extract Python files with multiple file markers
extract_python_artifact() {
local artifact_file="$1"
local description="$2"
print_step "Processing $description..."
if [ ! -f "$artifact_file" ]; then
print_warning "Artifact file $artifact_file not found, skipping..."
return
fi
# Use Python to parse the multi-file artifact
python3 << EOF
import re
import os
def extract_files(filename):
with open('$artifact_file', 'r') as f:
content = f.read()
# Split by file markers (lines starting with # and containing file paths)
files = {}
current_file = None
current_content = []
for line in content.split('\n'):
# Check for file path markers
if re.match(r'^#\s+\S+\.(py|yml|yaml|txt|sh|json|html|css|js|Dockerfile)', line):
# Save previous file
if current_file and current_content:
files[current_file] = '\n'.join(current_content)
# Start new file
current_file = re.sub(r'^#\s+', '', line)
current_content = []
elif current_file:
current_content.append(line)
# Save last file
if current_file and current_content:
files[current_file] = '\n'.join(current_content)
# Write files
for filepath, file_content in files.items():
# Clean up the content (remove leading/trailing quotes if present)
file_content = file_content.strip()
if file_content.startswith('"""') and file_content.endswith('"""'):
file_content = file_content[3:-3]
elif file_content.startswith("'''") and file_content.endswith("'''"):
file_content = file_content[3:-3]
# Create directory
os.makedirs(os.path.dirname(filepath) if os.path.dirname(filepath) else '.', exist_ok=True)
# Write file
with open(filepath, 'w') as f:
f.write(file_content)
print(f"✓ Created: {filepath}")
extract_files('$artifact_file')
EOF
}
# Create base project structure first
print_step "Creating base project structure..."
# Create main directories
mkdir -p {gateway,services/{auth,training,forecasting,data,tenant,notification},shared,frontend/{dashboard,marketing},infrastructure,deployment,tests,docs,scripts}
# Create subdirectories for each service
for service in auth training forecasting data tenant notification; do
mkdir -p services/$service/{app/{core,models,schemas,services,api,ml},migrations/versions,tests}
touch services/$service/app/__init__.py
touch services/$service/app/core/__init__.py
touch services/$service/app/models/__init__.py
touch services/$service/app/schemas/__init__.py
touch services/$service/app/services/__init__.py
touch services/$service/app/api/__init__.py
if [ "$service" = "training" ]; then
touch services/$service/app/ml/__init__.py
fi
done
# Create gateway structure
mkdir -p gateway/{app/{core,middleware,routes},tests}
touch gateway/app/__init__.py
touch gateway/app/core/__init__.py
touch gateway/app/middleware/__init__.py
touch gateway/app/routes/__init__.py
# Create shared library structure
mkdir -p shared/{auth,database,messaging,monitoring,utils}
for lib in auth database messaging monitoring utils; do
touch shared/$lib/__init__.py
done
# Create infrastructure directories
mkdir -p infrastructure/{docker,kubernetes,terraform,monitoring}/{base,dev,staging,production}
mkdir -p infrastructure/monitoring/{prometheus,grafana,logstash}
print_success "Base project structure created"
# Extract files from artifacts
print_step "Extracting files from artifacts..."
# Process shared libraries
if [ -f "shared_libraries.py" ]; then
extract_python_artifact "shared_libraries.py" "Shared Libraries"
fi
# Process gateway service
if [ -f "gateway_service.py" ]; then
extract_python_artifact "gateway_service.py" "Gateway Service"
fi
# Process auth service
if [ -f "auth_service.py" ]; then
extract_python_artifact "auth_service.py" "Authentication Service"
fi
# Process training service
if [ -f "training_service.py" ]; then
extract_python_artifact "training_service.py" "Training Service"
fi
print_step "Creating missing service files..."
# Create remaining service files that might not be in artifacts
for service in forecasting data tenant notification; do
service_dir="services/$service"
# Create main.py if it doesn't exist
if [ ! -f "$service_dir/app/main.py" ]; then
cat > "$service_dir/app/main.py" << EOF
"""
$(echo $service | sed 's/.*/\L&/; s/[a-z]*/\u&/g') Service
"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import database_manager
from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector
# Setup logging
setup_logging("$service-service", "INFO")
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="$(echo $service | sed 's/.*/\L&/; s/[a-z]*/\u&/g') Service",
description="$(echo $service | sed 's/.*/\L&/; s/[a-z]*/\u&/g') service for bakery forecasting",
version="1.0.0"
)
# Initialize metrics collector
metrics_collector = MetricsCollector("$service-service")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup_event():
"""Application startup"""
logger.info("Starting $(echo $service | sed 's/.*/\L&/; s/[a-z]*/\u&/g') Service")
# Create database tables
await database_manager.create_tables()
# Start metrics server
metrics_collector.start_metrics_server(8080)
logger.info("$(echo $service | sed 's/.*/\L&/; s/[a-z]*/\u&/g') Service started successfully")
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "$service-service",
"version": "1.0.0"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
EOF
print_success "Created: $service_dir/app/main.py"
fi
# Create config.py if it doesn't exist
if [ ! -f "$service_dir/app/core/config.py" ]; then
cat > "$service_dir/app/core/config.py" << EOF
"""
$(echo $service | sed 's/.*/\L&/; s/[a-z]*/\u&/g') service configuration
"""
import os
from pydantic import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
# Basic settings
APP_NAME: str = "$(echo $service | sed 's/.*/\L&/; s/[a-z]*/\u&/g') Service"
VERSION: str = "1.0.0"
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# Database settings
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://${service}_user:${service}_pass123@${service}-db:5432/${service}_db")
# Redis settings
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
# RabbitMQ settings
RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://bakery:forecast123@rabbitmq:5672/")
# Service URLs
AUTH_SERVICE_URL: str = os.getenv("AUTH_SERVICE_URL", "http://auth-service:8000")
class Config:
env_file = ".env"
settings = Settings()
EOF
print_success "Created: $service_dir/app/core/config.py"
fi
# Create database.py if it doesn't exist
if [ ! -f "$service_dir/app/core/database.py" ]; then
cat > "$service_dir/app/core/database.py" << EOF
"""
Database configuration for $service service
"""
from shared.database.base import DatabaseManager
from app.core.config import settings
# Initialize database manager
database_manager = DatabaseManager(settings.DATABASE_URL)
# Alias for convenience
get_db = database_manager.get_db
EOF
print_success "Created: $service_dir/app/core/database.py"
fi
# Create requirements.txt if it doesn't exist
if [ ! -f "$service_dir/requirements.txt" ]; then
cat > "$service_dir/requirements.txt" << 'EOF'
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
asyncpg==0.29.0
alembic==1.12.1
pydantic==2.5.0
pydantic-settings==2.1.0
httpx==0.25.2
redis==5.0.1
aio-pika==9.3.0
prometheus-client==0.17.1
python-json-logger==2.0.4
pytz==2023.3
EOF
print_success "Created: $service_dir/requirements.txt"
fi
# Create Dockerfile if it doesn't exist
if [ ! -f "$service_dir/Dockerfile" ]; then
cat > "$service_dir/Dockerfile" << 'EOF'
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
EOF
print_success "Created: $service_dir/Dockerfile"
fi
done
# Create .env file
print_step "Creating environment configuration..."
if [ ! -f ".env" ]; then
cat > .env << 'EOF'
# Environment
ENVIRONMENT=development
DEBUG=true
LOG_LEVEL=INFO
# Database URLs
AUTH_DB_URL=postgresql+asyncpg://auth_user:auth_pass123@auth-db:5432/auth_db
TRAINING_DB_URL=postgresql+asyncpg://training_user:training_pass123@training-db:5432/training_db
FORECASTING_DB_URL=postgresql+asyncpg://forecasting_user:forecasting_pass123@forecasting-db:5432/forecasting_db
DATA_DB_URL=postgresql+asyncpg://data_user:data_pass123@data-db:5432/data_db
TENANT_DB_URL=postgresql+asyncpg://tenant_user:tenant_pass123@tenant-db:5432/tenant_db
NOTIFICATION_DB_URL=postgresql+asyncpg://notification_user:notification_pass123@notification-db:5432/notification_db
# Redis
REDIS_URL=redis://redis:6379
# RabbitMQ
RABBITMQ_URL=amqp://bakery:forecast123@rabbitmq:5672/
# JWT
JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production-please
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
# External APIs
AEMET_API_KEY=your-aemet-api-key-here
MADRID_OPENDATA_API_KEY=your-madrid-opendata-key-here
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
# Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-email-password
# WhatsApp
WHATSAPP_API_KEY=your-whatsapp-api-key-here
# Monitoring
PROMETHEUS_URL=http://prometheus:9090
GRAFANA_URL=http://grafana:3000
EOF
print_success "Environment configuration created"
fi
# Create monitoring configuration
print_step "Creating monitoring configuration..."
if [ ! -f "infrastructure/monitoring/prometheus/prometheus.yml" ]; then
cat > infrastructure/monitoring/prometheus/prometheus.yml << 'EOF'
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'gateway'
static_configs:
- targets: ['gateway:8080']
- job_name: 'auth-service'
static_configs:
- targets: ['auth-service:8080']
- job_name: 'training-service'
static_configs:
- targets: ['training-service:8080']
- job_name: 'forecasting-service'
static_configs:
- targets: ['forecasting-service:8080']
- job_name: 'data-service'
static_configs:
- targets: ['data-service:8080']
- job_name: 'tenant-service'
static_configs:
- targets: ['tenant-service:8080']
- job_name: 'notification-service'
static_configs:
- targets: ['notification-service:8080']
EOF
print_success "Prometheus configuration created"
fi
# Create utility scripts
print_step "Creating utility scripts..."
# Create test script
cat > scripts/test.sh << 'EOF'
#!/bin/bash
echo "🧪 Running tests for all services..."
# Run tests for each service
for service in auth training forecasting data tenant notification; do
echo "Testing $service service..."
if docker-compose ps | grep -q "${service}-service.*Up"; then
docker-compose exec -T ${service}-service python -m pytest tests/ -v || echo "Tests failed for $service"
else
echo "Service $service is not running, skipping tests"
fi
done
echo "✅ Test run completed"
EOF
# Create deploy script
cat > scripts/deploy.sh << 'EOF'
#!/bin/bash
echo "🚀 Deploying Bakery Forecasting Platform..."
# Build and deploy all services
docker-compose build
docker-compose up -d
echo "Waiting for services to be healthy..."
sleep 30
# Check service health
echo "Checking service health..."
curl -f http://localhost:8000/health || echo "Gateway health check failed"
echo "✅ Deployment completed"
echo "Gateway: http://localhost:8000"
echo "API Docs: http://localhost:8000/docs"
EOF
# Make scripts executable
chmod +x scripts/*.sh
print_success "Utility scripts created"
# Create .gitignore
print_step "Creating .gitignore..."
if [ ! -f ".gitignore" ]; then
cat > .gitignore << 'EOF'
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.hypothesis/
.mypy_cache/
.dmyp.json
dmyp.json
.pyre/
# Virtual Environment
venv/
ENV/
env/
.venv
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.npm
.eslintcache
.next
out/
build/
dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
logs/
*.log
# Database
*.db
*.sqlite
*.sqlite3
# ML Models
*.pkl
*.joblib
*.h5
models/
# Data
data/external/
data/processed/
*.csv
*.xlsx
# Docker
.docker/
# Infrastructure
*.tfstate
*.tfstate.backup
.terraform/
.terraform.lock.hcl
# Kubernetes
kubeconfig
*.yaml.bak
# Monitoring
prometheus_data/
grafana_data/
elasticsearch_data/
# Artifacts (from Claude)
*_service.py
*_libraries.py
*.md
setup_scripts.sh
EOF
print_success ".gitignore created"
fi
# Create README
print_step "Creating documentation..."
if [ ! -f "README.md" ]; then
cat > README.md << 'EOF'
# Bakery Forecasting Platform - Microservices
## Overview
AI-powered demand forecasting platform for bakeries in Madrid, Spain using microservices architecture.
## Architecture
- **API Gateway**: Central entry point for all client requests
- **Auth Service**: User authentication and authorization
- **Training Service**: ML model training for demand forecasting
- **Forecasting Service**: Generate predictions using trained models
- **Data Service**: External data integration (weather, traffic, events)
- **Tenant Service**: Multi-tenant management
- **Notification Service**: Email and WhatsApp notifications
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Python 3.11+
- Node.js 18+
### Setup
```bash
# Run setup script (this script!)
./scripts/setup.sh
# Start services
docker-compose up -d
# Check service health
curl http://localhost:8000/health
```
### Services
- **Gateway**: http://localhost:8000
- **API Docs**: http://localhost:8000/docs
- **Grafana**: http://localhost:3002
- **Prometheus**: http://localhost:9090
- **RabbitMQ Management**: http://localhost:15672
### Development
#### Running Tests
```bash
./scripts/test.sh
```
#### Building Services
```bash
docker-compose build
```
#### Viewing Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f auth-service
```
#### Service URLs (Development)
- Gateway: http://localhost:8000
- Auth Service: http://localhost:8001
- Training Service: http://localhost:8002
- Forecasting Service: http://localhost:8003
- Data Service: http://localhost:8004
- Tenant Service: http://localhost:8005
- Notification Service: http://localhost:8006
## Environment Variables
Copy `.env.example` to `.env` and update the following:
```bash
# External API Keys
AEMET_API_KEY=your-aemet-api-key
MADRID_OPENDATA_API_KEY=your-madrid-opendata-key
# Email Configuration
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-email-password
# WhatsApp API
WHATSAPP_API_KEY=your-whatsapp-api-key
# JWT Secret (change in production!)
JWT_SECRET_KEY=your-super-secret-jwt-key
```
## Troubleshooting
### Services won't start
```bash
# Check if ports are available
docker-compose ps
netstat -tulpn | grep :8000
# Restart services
docker-compose down
docker-compose up -d
```
### Database connection issues
```bash
# Check database containers
docker-compose logs auth-db
docker-compose logs training-db
# Reset databases
docker-compose down -v
docker-compose up -d
```
### Service communication issues
```bash
# Check service health
curl http://localhost:8000/health
curl http://localhost:8001/health
curl http://localhost:8002/health
# Check RabbitMQ
open http://localhost:15672
# User: bakery, Password: forecast123
```
## Next Steps
1. **Configure External APIs**: Add your AEMET and Madrid Open Data API keys
2. **Test Authentication**: Register a user and test login
3. **Upload Sales Data**: Import historical sales data
4. **Train Models**: Start your first training job
5. **Generate Forecasts**: Create demand predictions
## License
MIT License
EOF
print_success "Documentation created"
fi
# Final steps
print_step "Final setup steps..."
# Copy shared libraries to each service (for Docker builds)
for service in auth training forecasting data tenant notification; do
if [ -d "shared" ]; then
cp -r shared services/$service/ 2>/dev/null || true
fi
done
# Copy shared libraries to gateway
if [ -d "shared" ]; then
cp -r shared gateway/ 2>/dev/null || true
fi
# Initialize Git repository if not exists
if [ ! -d ".git" ]; then
git init
git add .
git commit -m "Initial microservices setup from artifacts"
print_success "Git repository initialized"
fi
echo
echo "🎉 Setup completed successfully!"
echo "==============================================="
echo
echo "Next steps:"
echo "1. Update .env with your actual API keys"
echo "2. Start services: docker-compose up -d"
echo "3. Check health: curl http://localhost:8000/health"
echo "4. View API docs: http://localhost:8000/docs"
echo "5. Monitor services: http://localhost:3002 (Grafana)"
echo
echo "Services will be available at:"
echo "- Gateway: http://localhost:8000"
echo "- Auth Service: http://localhost:8001"
echo "- Training Service: http://localhost:8002"
echo "- Monitoring: http://localhost:3002"
echo "- RabbitMQ: http://localhost:15672"
echo
echo "Artifact files processed:"
[ -f "shared_libraries.py" ] && echo "✓ shared_libraries.py"
[ -f "gateway_service.py" ] && echo "✓ gateway_service.py"
[ -f "auth_service.py" ] && echo "✓ auth_service.py"
[ -f "training_service.py" ] && echo "✓ training_service.py"
[ -f "docker-compose.yml" ] && echo "✓ docker-compose.yml"
echo
echo "Happy coding! 🚀"

15
scripts/test.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
echo "🧪 Running tests for all services..."
# Run tests for each service
for service in auth training forecasting data tenant notification; do
echo "Testing $service service..."
if docker-compose ps | grep -q "${service}-service.*Up"; then
docker-compose exec -T ${service}-service python -m pytest tests/ -v || echo "Tests failed for $service"
else
echo "Service $service is not running, skipping tests"
fi
done
echo "✅ Test run completed"

View File

View File

View File

@@ -0,0 +1,124 @@
"""
Authentication API routes
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
import logging
from app.core.database import get_db
from app.schemas.auth import UserRegistration, UserLogin, TokenResponse, RefreshTokenRequest, UserResponse
from app.services.auth_service import AuthService
from app.core.security import security_manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/register", response_model=UserResponse)
async def register(
user_data: UserRegistration,
db: AsyncSession = Depends(get_db)
):
"""Register a new user"""
try:
return await AuthService.register_user(user_data, db)
except HTTPException:
raise
except Exception as e:
logger.error(f"Registration error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration failed"
)
@router.post("/login", response_model=TokenResponse)
async def login(
login_data: UserLogin,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""User login"""
try:
ip_address = request.client.host
user_agent = request.headers.get("user-agent", "")
return await AuthService.login_user(login_data, db, ip_address, user_agent)
except HTTPException:
raise
except Exception as e:
logger.error(f"Login error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed"
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
refresh_data: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
):
"""Refresh access token"""
try:
return await AuthService.refresh_token(refresh_data.refresh_token, db)
except HTTPException:
raise
except Exception as e:
logger.error(f"Token refresh error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Token refresh failed"
)
@router.post("/verify")
async def verify_token(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Verify access token"""
try:
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header required"
)
token = auth_header.split(" ")[1]
return await AuthService.verify_token(token, db)
except HTTPException:
raise
except Exception as e:
logger.error(f"Token verification error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Token verification failed"
)
@router.post("/logout")
async def logout(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""User logout"""
try:
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header required"
)
token = auth_header.split(" ")[1]
user_data = await AuthService.verify_token(token, db)
await AuthService.logout_user(user_data["user_id"], db)
return {"message": "Logged out successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Logout error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Logout failed"
)

View File

View File

@@ -0,0 +1,47 @@
"""
Authentication service configuration
"""
import os
from pydantic import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
# Basic settings
APP_NAME: str = "Authentication Service"
VERSION: str = "1.0.0"
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# Database settings
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://auth_user:auth_pass123@auth-db:5432/auth_db")
# Redis settings
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
# JWT settings
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "your-super-secret-jwt-key")
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# Password settings
PASSWORD_MIN_LENGTH: int = 8
PASSWORD_REQUIRE_UPPERCASE: bool = True
PASSWORD_REQUIRE_LOWERCASE: bool = True
PASSWORD_REQUIRE_NUMBERS: bool = True
PASSWORD_REQUIRE_SYMBOLS: bool = False
# Security settings
BCRYPT_ROUNDS: int = 12
MAX_LOGIN_ATTEMPTS: int = 5
LOCKOUT_DURATION_MINUTES: int = 30
# RabbitMQ settings
RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://bakery:forecast123@rabbitmq:5672/")
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,12 @@
"""
Database configuration for auth service
"""
from shared.database.base import DatabaseManager
from app.core.config import settings
# Initialize database manager
database_manager = DatabaseManager(settings.DATABASE_URL)
# Alias for convenience
get_db = database_manager.get_db

View File

@@ -0,0 +1,153 @@
"""
Security utilities for authentication service
"""
import bcrypt
import re
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import redis.asyncio as redis
from fastapi import HTTPException, status
import logging
from app.core.config import settings
from shared.auth.jwt_handler import JWTHandler
logger = logging.getLogger(__name__)
# Initialize JWT handler
jwt_handler = JWTHandler(settings.JWT_SECRET_KEY, settings.JWT_ALGORITHM)
# Redis client for session management
redis_client = redis.from_url(settings.REDIS_URL)
class SecurityManager:
"""Security utilities for authentication"""
@staticmethod
def hash_password(password: str) -> str:
"""Hash password using bcrypt"""
salt = bcrypt.gensalt(rounds=settings.BCRYPT_ROUNDS)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
@staticmethod
def verify_password(password: str, hashed_password: str) -> bool:
"""Verify password against hash"""
return bcrypt.checkpw(password.encode('utf-8'), hashed_password.encode('utf-8'))
@staticmethod
def validate_password(password: str) -> bool:
"""Validate password strength"""
if len(password) < settings.PASSWORD_MIN_LENGTH:
return False
if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
return False
if settings.PASSWORD_REQUIRE_LOWERCASE and not re.search(r'[a-z]', password):
return False
if settings.PASSWORD_REQUIRE_NUMBERS and not re.search(r'\d', password):
return False
if settings.PASSWORD_REQUIRE_SYMBOLS and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
return False
return True
@staticmethod
def create_access_token(user_data: Dict[str, Any]) -> str:
"""Create JWT access token"""
expires_delta = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt_handler.create_access_token(user_data, expires_delta)
@staticmethod
def create_refresh_token(user_data: Dict[str, Any]) -> str:
"""Create JWT refresh token"""
expires_delta = timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
return jwt_handler.create_refresh_token(user_data, expires_delta)
@staticmethod
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""Verify JWT token"""
return jwt_handler.verify_token(token)
@staticmethod
async def check_login_attempts(email: str) -> bool:
"""Check if user has exceeded login attempts"""
try:
key = f"login_attempts:{email}"
attempts = await redis_client.get(key)
if attempts is None:
return True
return int(attempts) < settings.MAX_LOGIN_ATTEMPTS
except Exception as e:
logger.error(f"Error checking login attempts: {e}")
return True
@staticmethod
async def increment_login_attempts(email: str):
"""Increment login attempts counter"""
try:
key = f"login_attempts:{email}"
current_attempts = await redis_client.incr(key)
# Set TTL on first attempt
if current_attempts == 1:
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
except Exception as e:
logger.error(f"Error incrementing login attempts: {e}")
@staticmethod
async def clear_login_attempts(email: str):
"""Clear login attempts counter"""
try:
key = f"login_attempts:{email}"
await redis_client.delete(key)
except Exception as e:
logger.error(f"Error clearing login attempts: {e}")
@staticmethod
async def store_refresh_token(user_id: str, refresh_token: str):
"""Store refresh token in Redis"""
try:
key = f"refresh_token:{user_id}"
expires_seconds = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600
await redis_client.setex(key, expires_seconds, refresh_token)
except Exception as e:
logger.error(f"Error storing refresh token: {e}")
@staticmethod
async def verify_refresh_token(user_id: str, refresh_token: str) -> bool:
"""Verify refresh token"""
try:
key = f"refresh_token:{user_id}"
stored_token = await redis_client.get(key)
if stored_token is None:
return False
return stored_token.decode() == refresh_token
except Exception as e:
logger.error(f"Error verifying refresh token: {e}")
return False
@staticmethod
async def revoke_refresh_token(user_id: str):
"""Revoke refresh token"""
try:
key = f"refresh_token:{user_id}"
await redis_client.delete(key)
except Exception as e:
logger.error(f"Error revoking refresh token: {e}")
# Global security manager instance
security_manager = SecurityManager()

83
services/auth/app/main.py Normal file
View File

@@ -0,0 +1,83 @@
"""
Authentication Service
Handles user authentication, registration, and token management
"""
import logging
from datetime import timedelta
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer
from app.core.config import settings
from app.core.database import database_manager
from app.api import auth, users
from app.services.messaging import message_publisher
from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector
# Setup logging
setup_logging("auth-service", settings.LOG_LEVEL)
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="Authentication Service",
description="User authentication and authorization service",
version="1.0.0"
)
# Initialize metrics collector
metrics_collector = MetricsCollector("auth-service")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/auth", tags=["authentication"])
app.include_router(users.router, prefix="/users", tags=["users"])
@app.on_event("startup")
async def startup_event():
"""Application startup"""
logger.info("Starting Authentication Service")
# Create database tables
await database_manager.create_tables()
# Initialize message publisher
await message_publisher.connect()
# Start metrics server
metrics_collector.start_metrics_server(8080)
logger.info("Authentication Service started successfully")
@app.on_event("shutdown")
async def shutdown_event():
"""Application shutdown"""
logger.info("Shutting down Authentication Service")
# Cleanup message publisher
await message_publisher.disconnect()
logger.info("Authentication Service shutdown complete")
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "auth-service",
"version": "1.0.0"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

View File

@@ -0,0 +1,108 @@
"""
Authentication schemas
"""
from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional
from datetime import datetime
from app.core.config import settings
from shared.utils.validation import validate_spanish_phone
class UserRegistration(BaseModel):
"""User registration schema"""
email: EmailStr
password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH)
full_name: str = Field(..., min_length=2, max_length=100)
phone: Optional[str] = None
language: str = Field(default="es", regex="^(es|en)$")
@validator('password')
def validate_password(cls, v):
"""Validate password strength"""
from app.core.security import security_manager
if not security_manager.validate_password(v):
raise ValueError('Password does not meet security requirements')
return v
@validator('phone')
def validate_phone(cls, v):
"""Validate phone number"""
if v and not validate_spanish_phone(v):
raise ValueError('Invalid Spanish phone number')
return v
class UserLogin(BaseModel):
"""User login schema"""
email: EmailStr
password: str
class TokenResponse(BaseModel):
"""Token response schema"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class RefreshTokenRequest(BaseModel):
"""Refresh token request schema"""
refresh_token: str
class UserResponse(BaseModel):
"""User response schema"""
id: str
email: str
full_name: str
is_active: bool
is_verified: bool
tenant_id: Optional[str]
role: str
phone: Optional[str]
language: str
timezone: str
created_at: Optional[datetime]
last_login: Optional[datetime]
class PasswordChangeRequest(BaseModel):
"""Password change request schema"""
current_password: str
new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH)
@validator('new_password')
def validate_new_password(cls, v):
"""Validate new password strength"""
from app.core.security import security_manager
if not security_manager.validate_password(v):
raise ValueError('New password does not meet security requirements')
return v
class PasswordResetRequest(BaseModel):
"""Password reset request schema"""
email: EmailStr
class PasswordResetConfirm(BaseModel):
"""Password reset confirmation schema"""
token: str
new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH)
@validator('new_password')
def validate_new_password(cls, v):
"""Validate new password strength"""
from app.core.security import security_manager
if not security_manager.validate_password(v):
raise ValueError('New password does not meet security requirements')
return v
class UserUpdate(BaseModel):
"""User update schema"""
full_name: Optional[str] = Field(None, min_length=2, max_length=100)
phone: Optional[str] = None
language: Optional[str] = Field(None, regex="^(es|en)$")
timezone: Optional[str] = None
@validator('phone')
def validate_phone(cls, v):
"""Validate phone number"""
if v and not validate_spanish_phone(v):
raise ValueError('Invalid Spanish phone number')
return v

View File

View File

@@ -0,0 +1,46 @@
"""
Messaging service for auth service
"""
from shared.messaging.rabbitmq import RabbitMQClient
from app.core.config import settings
# Global message publisher
message_publisher = RabbitMQClient(settings.RABBITMQ_URL)
# services/auth/Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy shared libraries
COPY --from=shared /shared /app/shared
# Copy application code
COPY . .
# Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,17 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
asyncpg==0.29.0
alembic==1.12.1
pydantic==2.5.0
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.6
redis==5.0.1
aio-pika==9.3.0
email-validator==2.0.0
prometheus-client==0.17.1
python-json-logger==2.0.4
pytz==2023.3

View File

View File

@@ -0,0 +1,41 @@
"""
Authentication decorators for FastAPI
"""
from functools import wraps
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer
import httpx
import logging
logger = logging.getLogger(__name__)
security = HTTPBearer()
def verify_service_token(auth_service_url: str):
"""Verify service token with auth service"""
async def verify_token(token: str = Depends(security)):
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{auth_service_url}/verify",
headers={"Authorization": f"Bearer {token.credentials}"}
)
if response.status_code == 200:
return response.json()
else:
raise HTTPException(
status_code=401,
detail="Invalid authentication credentials"
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
return verify_token

View File

@@ -0,0 +1,58 @@
"""
Shared JWT Authentication Handler
Used across all microservices for consistent authentication
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
class JWTHandler:
"""JWT token handling for microservices"""
def __init__(self, secret_key: str, algorithm: str = "HS256"):
self.secret_key = secret_key
self.algorithm = algorithm
def create_access_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=30)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def create_refresh_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT refresh token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=7)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify and decode JWT token"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
return payload
except jwt.ExpiredSignatureError:
logger.warning("Token has expired")
return None
except jwt.InvalidTokenError:
logger.warning("Invalid token")
return None

View File

@@ -0,0 +1,56 @@
"""
Base database configuration for all microservices
"""
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.pool import StaticPool
import logging
logger = logging.getLogger(__name__)
Base = declarative_base()
class DatabaseManager:
"""Database manager for microservices"""
def __init__(self, database_url: str):
self.database_url = database_url
self.async_engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_recycle=300,
pool_size=20,
max_overflow=30
)
self.async_session_local = sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db(self):
"""Get database session"""
async with self.async_session_local() as session:
try:
yield session
except Exception as e:
logger.error(f"Database session error: {e}")
await session.rollback()
raise
finally:
await session.close()
async def create_tables(self):
"""Create database tables"""
async with self.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_tables(self):
"""Drop database tables"""
async with self.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)

View File

@@ -0,0 +1,73 @@
"""
Event definitions for microservices communication
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Any, Optional
import uuid
@dataclass
class BaseEvent:
"""Base event class"""
event_id: str
event_type: str
service_name: str
timestamp: datetime
data: Dict[str, Any]
correlation_id: Optional[str] = None
def __post_init__(self):
if not self.event_id:
self.event_id = str(uuid.uuid4())
if not self.timestamp:
self.timestamp = datetime.utcnow()
# Training Events
@dataclass
class TrainingStartedEvent(BaseEvent):
event_type: str = "training.started"
@dataclass
class TrainingCompletedEvent(BaseEvent):
event_type: str = "training.completed"
@dataclass
class TrainingFailedEvent(BaseEvent):
event_type: str = "training.failed"
# Forecasting Events
@dataclass
class ForecastGeneratedEvent(BaseEvent):
event_type: str = "forecast.generated"
@dataclass
class ForecastRequestedEvent(BaseEvent):
event_type: str = "forecast.requested"
# User Events
@dataclass
class UserRegisteredEvent(BaseEvent):
event_type: str = "user.registered"
@dataclass
class UserLoginEvent(BaseEvent):
event_type: str = "user.login"
# Tenant Events
@dataclass
class TenantCreatedEvent(BaseEvent):
event_type: str = "tenant.created"
@dataclass
class TenantUpdatedEvent(BaseEvent):
event_type: str = "tenant.updated"
# Notification Events
@dataclass
class NotificationSentEvent(BaseEvent):
event_type: str = "notification.sent"
@dataclass
class NotificationFailedEvent(BaseEvent):
event_type: str = "notification.failed"

View File

@@ -0,0 +1,96 @@
"""
RabbitMQ messaging client for microservices
"""
import asyncio
import json
import logging
from typing import Dict, Any, Callable
import aio_pika
from aio_pika import connect_robust, Message, DeliveryMode
logger = logging.getLogger(__name__)
class RabbitMQClient:
"""RabbitMQ client for microservices communication"""
def __init__(self, connection_url: str):
self.connection_url = connection_url
self.connection = None
self.channel = None
async def connect(self):
"""Connect to RabbitMQ"""
try:
self.connection = await connect_robust(self.connection_url)
self.channel = await self.connection.channel()
logger.info("Connected to RabbitMQ")
except Exception as e:
logger.error(f"Failed to connect to RabbitMQ: {e}")
raise
async def disconnect(self):
"""Disconnect from RabbitMQ"""
if self.connection:
await self.connection.close()
logger.info("Disconnected from RabbitMQ")
async def publish_event(self, exchange_name: str, routing_key: str, event_data: Dict[str, Any]):
"""Publish event to RabbitMQ"""
try:
if not self.channel:
await self.connect()
# Declare exchange
exchange = await self.channel.declare_exchange(
exchange_name,
aio_pika.ExchangeType.TOPIC,
durable=True
)
# Create message
message = Message(
json.dumps(event_data).encode(),
delivery_mode=DeliveryMode.PERSISTENT,
content_type="application/json"
)
# Publish message
await exchange.publish(message, routing_key=routing_key)
logger.info(f"Published event to {exchange_name} with routing key {routing_key}")
except Exception as e:
logger.error(f"Failed to publish event: {e}")
raise
async def consume_events(self, exchange_name: str, queue_name: str, routing_key: str, callback: Callable):
"""Consume events from RabbitMQ"""
try:
if not self.channel:
await self.connect()
# Declare exchange
exchange = await self.channel.declare_exchange(
exchange_name,
aio_pika.ExchangeType.TOPIC,
durable=True
)
# Declare queue
queue = await self.channel.declare_queue(
queue_name,
durable=True
)
# Bind queue to exchange
await queue.bind(exchange, routing_key)
# Set up consumer
await queue.consume(callback)
logger.info(f"Started consuming events from {queue_name}")
except Exception as e:
logger.error(f"Failed to consume events: {e}")
raise

View File

@@ -0,0 +1,77 @@
"""
Centralized logging configuration for microservices
"""
import logging
import logging.config
import os
from typing import Dict, Any
def setup_logging(service_name: str, log_level: str = "INFO") -> None:
"""Set up logging configuration for a microservice"""
config: Dict[str, Any] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
},
"detailed": {
"format": "%(asctime)s [%(levelname)s] %(name)s [%(filename)s:%(lineno)d] %(message)s"
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": log_level,
"formatter": "standard",
"stream": "ext://sys.stdout"
},
"file": {
"class": "logging.FileHandler",
"level": log_level,
"formatter": "detailed",
"filename": f"/var/log/{service_name}.log",
"mode": "a"
},
"logstash": {
"class": "logstash.TCPLogstashHandler",
"host": os.getenv("LOGSTASH_HOST", "localhost"),
"port": int(os.getenv("LOGSTASH_PORT", "5000")),
"version": 1,
"message_type": "logstash",
"fqdn": False,
"tags": [service_name]
}
},
"loggers": {
"": {
"handlers": ["console", "file"],
"level": log_level,
"propagate": False
},
"uvicorn": {
"handlers": ["console"],
"level": log_level,
"propagate": False
},
"uvicorn.access": {
"handlers": ["console"],
"level": log_level,
"propagate": False
}
}
}
# Add logstash handler if in production
if os.getenv("ENVIRONMENT") == "production":
config["loggers"][""]["handlers"].append("logstash")
logging.config.dictConfig(config)
logger = logging.getLogger(__name__)
logger.info(f"Logging configured for {service_name}")

View File

@@ -0,0 +1,112 @@
"""
Metrics collection for microservices
"""
import time
import logging
from typing import Dict, Any
from prometheus_client import Counter, Histogram, Gauge, start_http_server
from functools import wraps
logger = logging.getLogger(__name__)
# Prometheus metrics
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status_code', 'service']
)
REQUEST_DURATION = Histogram(
'http_request_duration_seconds',
'HTTP request duration in seconds',
['method', 'endpoint', 'service']
)
ACTIVE_CONNECTIONS = Gauge(
'active_connections',
'Active database connections',
['service']
)
TRAINING_JOBS = Counter(
'training_jobs_total',
'Total training jobs',
['status', 'service']
)
FORECASTS_GENERATED = Counter(
'forecasts_generated_total',
'Total forecasts generated',
['service']
)
class MetricsCollector:
"""Metrics collector for microservices"""
def __init__(self, service_name: str):
self.service_name = service_name
self.start_time = time.time()
def start_metrics_server(self, port: int = 8080):
"""Start Prometheus metrics server"""
try:
start_http_server(port)
logger.info(f"Metrics server started on port {port}")
except Exception as e:
logger.error(f"Failed to start metrics server: {e}")
def record_request(self, method: str, endpoint: str, status_code: int, duration: float):
"""Record HTTP request metrics"""
REQUEST_COUNT.labels(
method=method,
endpoint=endpoint,
status_code=status_code,
service=self.service_name
).inc()
REQUEST_DURATION.labels(
method=method,
endpoint=endpoint,
service=self.service_name
).observe(duration)
def record_training_job(self, status: str):
"""Record training job metrics"""
TRAINING_JOBS.labels(
status=status,
service=self.service_name
).inc()
def record_forecast_generated(self):
"""Record forecast generation metrics"""
FORECASTS_GENERATED.labels(
service=self.service_name
).inc()
def set_active_connections(self, count: int):
"""Set active database connections"""
ACTIVE_CONNECTIONS.labels(
service=self.service_name
).set(count)
def metrics_middleware(metrics_collector: MetricsCollector):
"""Middleware to collect metrics"""
def middleware(request, call_next):
start_time = time.time()
response = call_next(request)
duration = time.time() - start_time
metrics_collector.record_request(
method=request.method,
endpoint=request.url.path,
status_code=response.status_code,
duration=duration
)
return response
return middleware

View File

View File

@@ -0,0 +1,71 @@
"""
DateTime utilities for microservices
"""
from datetime import datetime, timezone, timedelta
from typing import Optional
import pytz
def utc_now() -> datetime:
"""Get current UTC datetime"""
return datetime.now(timezone.utc)
def madrid_now() -> datetime:
"""Get current Madrid datetime"""
madrid_tz = pytz.timezone('Europe/Madrid')
return datetime.now(madrid_tz)
def to_utc(dt: datetime) -> datetime:
"""Convert datetime to UTC"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_madrid(dt: datetime) -> datetime:
"""Convert datetime to Madrid timezone"""
madrid_tz = pytz.timezone('Europe/Madrid')
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(madrid_tz)
def format_datetime(dt: datetime, format_str: str = "%Y-%m-%d %H:%M:%S") -> str:
"""Format datetime as string"""
return dt.strftime(format_str)
def parse_datetime(dt_str: str, format_str: str = "%Y-%m-%d %H:%M:%S") -> datetime:
"""Parse datetime from string"""
return datetime.strptime(dt_str, format_str)
def is_business_hours(dt: Optional[datetime] = None) -> bool:
"""Check if datetime is during business hours (9 AM - 6 PM Madrid time)"""
if dt is None:
dt = madrid_now()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
madrid_dt = to_madrid(dt)
# Check if it's a weekday (Monday=0, Sunday=6)
if madrid_dt.weekday() >= 5: # Weekend
return False
# Check if it's business hours
return 9 <= madrid_dt.hour < 18
def next_business_day(dt: Optional[datetime] = None) -> datetime:
"""Get next business day"""
if dt is None:
dt = madrid_now()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
madrid_dt = to_madrid(dt)
# Add days until we reach a weekday
while madrid_dt.weekday() >= 5: # Weekend
madrid_dt += timedelta(days=1)
# Set to 9 AM
return madrid_dt.replace(hour=9, minute=0, second=0, microsecond=0)

View File

@@ -0,0 +1,67 @@
"""
Validation utilities for microservices
"""
import re
from typing import Any, Optional
from email_validator import validate_email, EmailNotValidError
def validate_spanish_phone(phone: str) -> bool:
"""Validate Spanish phone number"""
# Spanish phone pattern: +34 followed by 9 digits
pattern = r'^(\+34|0034|34)?[6-9]\d{8}$'
return bool(re.match(pattern, phone.replace(' ', '').replace('-', '')))
def validate_email_address(email: str) -> bool:
"""Validate email address"""
try:
validate_email(email)
return True
except EmailNotValidError:
return False
def validate_tenant_name(name: str) -> bool:
"""Validate tenant name"""
# Must be 2-50 characters, letters, numbers, spaces, hyphens, apostrophes
pattern = r"^[a-zA-ZÀ-ÿ0-9\s\-']{2,50}$"
return bool(re.match(pattern, name))
def validate_address(address: str) -> bool:
"""Validate address"""
# Must be 5-200 characters
return 5 <= len(address.strip()) <= 200
def validate_coordinates(latitude: float, longitude: float) -> bool:
"""Validate Madrid coordinates"""
# Madrid is roughly between these coordinates
madrid_bounds = {
'lat_min': 40.3,
'lat_max': 40.6,
'lon_min': -3.8,
'lon_max': -3.5
}
return (
madrid_bounds['lat_min'] <= latitude <= madrid_bounds['lat_max'] and
madrid_bounds['lon_min'] <= longitude <= madrid_bounds['lon_max']
)
def validate_product_name(name: str) -> bool:
"""Validate product name"""
# Must be 1-50 characters, letters, numbers, spaces
pattern = r"^[a-zA-ZÀ-ÿ0-9\s]{1,50}$"
return bool(re.match(pattern, name))
def validate_positive_number(value: Any) -> bool:
"""Validate positive number"""
try:
return float(value) > 0
except (ValueError, TypeError):
return False
def validate_non_negative_number(value: Any) -> bool:
"""Validate non-negative number"""
try:
return float(value) >= 0
except (ValueError, TypeError):
return False

31
services/data/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

View File

View File

View File

@@ -0,0 +1,32 @@
"""
uLudata service configuration
"""
import os
from pydantic import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
# Basic settings
APP_NAME: str = "uLudata Service"
VERSION: str = "1.0.0"
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# Database settings
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://data_user:data_pass123@data-db:5432/data_db")
# Redis settings
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
# RabbitMQ settings
RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://bakery:forecast123@rabbitmq:5672/")
# Service URLs
AUTH_SERVICE_URL: str = os.getenv("AUTH_SERVICE_URL", "http://auth-service:8000")
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,12 @@
"""
Database configuration for data service
"""
from shared.database.base import DatabaseManager
from app.core.config import settings
# Initialize database manager
database_manager = DatabaseManager(settings.DATABASE_URL)
# Alias for convenience
get_db = database_manager.get_db

61
services/data/app/main.py Normal file
View File

@@ -0,0 +1,61 @@
"""
uLudata Service
"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import database_manager
from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector
# Setup logging
setup_logging("data-service", "INFO")
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="uLudata Service",
description="uLudata service for bakery forecasting",
version="1.0.0"
)
# Initialize metrics collector
metrics_collector = MetricsCollector("data-service")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup_event():
"""Application startup"""
logger.info("Starting uLudata Service")
# Create database tables
await database_manager.create_tables()
# Start metrics server
metrics_collector.start_metrics_server(8080)
logger.info("uLudata Service started successfully")
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "data-service",
"version": "1.0.0"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

View File

View File

@@ -0,0 +1,13 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
asyncpg==0.29.0
alembic==1.12.1
pydantic==2.5.0
pydantic-settings==2.1.0
httpx==0.25.2
redis==5.0.1
aio-pika==9.3.0
prometheus-client==0.17.1
python-json-logger==2.0.4
pytz==2023.3

View File

View File

@@ -0,0 +1,41 @@
"""
Authentication decorators for FastAPI
"""
from functools import wraps
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer
import httpx
import logging
logger = logging.getLogger(__name__)
security = HTTPBearer()
def verify_service_token(auth_service_url: str):
"""Verify service token with auth service"""
async def verify_token(token: str = Depends(security)):
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{auth_service_url}/verify",
headers={"Authorization": f"Bearer {token.credentials}"}
)
if response.status_code == 200:
return response.json()
else:
raise HTTPException(
status_code=401,
detail="Invalid authentication credentials"
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
return verify_token

View File

@@ -0,0 +1,58 @@
"""
Shared JWT Authentication Handler
Used across all microservices for consistent authentication
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
class JWTHandler:
"""JWT token handling for microservices"""
def __init__(self, secret_key: str, algorithm: str = "HS256"):
self.secret_key = secret_key
self.algorithm = algorithm
def create_access_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=30)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def create_refresh_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT refresh token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=7)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify and decode JWT token"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
return payload
except jwt.ExpiredSignatureError:
logger.warning("Token has expired")
return None
except jwt.InvalidTokenError:
logger.warning("Invalid token")
return None

View File

@@ -0,0 +1,56 @@
"""
Base database configuration for all microservices
"""
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.pool import StaticPool
import logging
logger = logging.getLogger(__name__)
Base = declarative_base()
class DatabaseManager:
"""Database manager for microservices"""
def __init__(self, database_url: str):
self.database_url = database_url
self.async_engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_recycle=300,
pool_size=20,
max_overflow=30
)
self.async_session_local = sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db(self):
"""Get database session"""
async with self.async_session_local() as session:
try:
yield session
except Exception as e:
logger.error(f"Database session error: {e}")
await session.rollback()
raise
finally:
await session.close()
async def create_tables(self):
"""Create database tables"""
async with self.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_tables(self):
"""Drop database tables"""
async with self.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)

View File

@@ -0,0 +1,73 @@
"""
Event definitions for microservices communication
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Any, Optional
import uuid
@dataclass
class BaseEvent:
"""Base event class"""
event_id: str
event_type: str
service_name: str
timestamp: datetime
data: Dict[str, Any]
correlation_id: Optional[str] = None
def __post_init__(self):
if not self.event_id:
self.event_id = str(uuid.uuid4())
if not self.timestamp:
self.timestamp = datetime.utcnow()
# Training Events
@dataclass
class TrainingStartedEvent(BaseEvent):
event_type: str = "training.started"
@dataclass
class TrainingCompletedEvent(BaseEvent):
event_type: str = "training.completed"
@dataclass
class TrainingFailedEvent(BaseEvent):
event_type: str = "training.failed"
# Forecasting Events
@dataclass
class ForecastGeneratedEvent(BaseEvent):
event_type: str = "forecast.generated"
@dataclass
class ForecastRequestedEvent(BaseEvent):
event_type: str = "forecast.requested"
# User Events
@dataclass
class UserRegisteredEvent(BaseEvent):
event_type: str = "user.registered"
@dataclass
class UserLoginEvent(BaseEvent):
event_type: str = "user.login"
# Tenant Events
@dataclass
class TenantCreatedEvent(BaseEvent):
event_type: str = "tenant.created"
@dataclass
class TenantUpdatedEvent(BaseEvent):
event_type: str = "tenant.updated"
# Notification Events
@dataclass
class NotificationSentEvent(BaseEvent):
event_type: str = "notification.sent"
@dataclass
class NotificationFailedEvent(BaseEvent):
event_type: str = "notification.failed"

View File

@@ -0,0 +1,96 @@
"""
RabbitMQ messaging client for microservices
"""
import asyncio
import json
import logging
from typing import Dict, Any, Callable
import aio_pika
from aio_pika import connect_robust, Message, DeliveryMode
logger = logging.getLogger(__name__)
class RabbitMQClient:
"""RabbitMQ client for microservices communication"""
def __init__(self, connection_url: str):
self.connection_url = connection_url
self.connection = None
self.channel = None
async def connect(self):
"""Connect to RabbitMQ"""
try:
self.connection = await connect_robust(self.connection_url)
self.channel = await self.connection.channel()
logger.info("Connected to RabbitMQ")
except Exception as e:
logger.error(f"Failed to connect to RabbitMQ: {e}")
raise
async def disconnect(self):
"""Disconnect from RabbitMQ"""
if self.connection:
await self.connection.close()
logger.info("Disconnected from RabbitMQ")
async def publish_event(self, exchange_name: str, routing_key: str, event_data: Dict[str, Any]):
"""Publish event to RabbitMQ"""
try:
if not self.channel:
await self.connect()
# Declare exchange
exchange = await self.channel.declare_exchange(
exchange_name,
aio_pika.ExchangeType.TOPIC,
durable=True
)
# Create message
message = Message(
json.dumps(event_data).encode(),
delivery_mode=DeliveryMode.PERSISTENT,
content_type="application/json"
)
# Publish message
await exchange.publish(message, routing_key=routing_key)
logger.info(f"Published event to {exchange_name} with routing key {routing_key}")
except Exception as e:
logger.error(f"Failed to publish event: {e}")
raise
async def consume_events(self, exchange_name: str, queue_name: str, routing_key: str, callback: Callable):
"""Consume events from RabbitMQ"""
try:
if not self.channel:
await self.connect()
# Declare exchange
exchange = await self.channel.declare_exchange(
exchange_name,
aio_pika.ExchangeType.TOPIC,
durable=True
)
# Declare queue
queue = await self.channel.declare_queue(
queue_name,
durable=True
)
# Bind queue to exchange
await queue.bind(exchange, routing_key)
# Set up consumer
await queue.consume(callback)
logger.info(f"Started consuming events from {queue_name}")
except Exception as e:
logger.error(f"Failed to consume events: {e}")
raise

View File

@@ -0,0 +1,77 @@
"""
Centralized logging configuration for microservices
"""
import logging
import logging.config
import os
from typing import Dict, Any
def setup_logging(service_name: str, log_level: str = "INFO") -> None:
"""Set up logging configuration for a microservice"""
config: Dict[str, Any] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
},
"detailed": {
"format": "%(asctime)s [%(levelname)s] %(name)s [%(filename)s:%(lineno)d] %(message)s"
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": log_level,
"formatter": "standard",
"stream": "ext://sys.stdout"
},
"file": {
"class": "logging.FileHandler",
"level": log_level,
"formatter": "detailed",
"filename": f"/var/log/{service_name}.log",
"mode": "a"
},
"logstash": {
"class": "logstash.TCPLogstashHandler",
"host": os.getenv("LOGSTASH_HOST", "localhost"),
"port": int(os.getenv("LOGSTASH_PORT", "5000")),
"version": 1,
"message_type": "logstash",
"fqdn": False,
"tags": [service_name]
}
},
"loggers": {
"": {
"handlers": ["console", "file"],
"level": log_level,
"propagate": False
},
"uvicorn": {
"handlers": ["console"],
"level": log_level,
"propagate": False
},
"uvicorn.access": {
"handlers": ["console"],
"level": log_level,
"propagate": False
}
}
}
# Add logstash handler if in production
if os.getenv("ENVIRONMENT") == "production":
config["loggers"][""]["handlers"].append("logstash")
logging.config.dictConfig(config)
logger = logging.getLogger(__name__)
logger.info(f"Logging configured for {service_name}")

View File

@@ -0,0 +1,112 @@
"""
Metrics collection for microservices
"""
import time
import logging
from typing import Dict, Any
from prometheus_client import Counter, Histogram, Gauge, start_http_server
from functools import wraps
logger = logging.getLogger(__name__)
# Prometheus metrics
REQUEST_COUNT = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status_code', 'service']
)
REQUEST_DURATION = Histogram(
'http_request_duration_seconds',
'HTTP request duration in seconds',
['method', 'endpoint', 'service']
)
ACTIVE_CONNECTIONS = Gauge(
'active_connections',
'Active database connections',
['service']
)
TRAINING_JOBS = Counter(
'training_jobs_total',
'Total training jobs',
['status', 'service']
)
FORECASTS_GENERATED = Counter(
'forecasts_generated_total',
'Total forecasts generated',
['service']
)
class MetricsCollector:
"""Metrics collector for microservices"""
def __init__(self, service_name: str):
self.service_name = service_name
self.start_time = time.time()
def start_metrics_server(self, port: int = 8080):
"""Start Prometheus metrics server"""
try:
start_http_server(port)
logger.info(f"Metrics server started on port {port}")
except Exception as e:
logger.error(f"Failed to start metrics server: {e}")
def record_request(self, method: str, endpoint: str, status_code: int, duration: float):
"""Record HTTP request metrics"""
REQUEST_COUNT.labels(
method=method,
endpoint=endpoint,
status_code=status_code,
service=self.service_name
).inc()
REQUEST_DURATION.labels(
method=method,
endpoint=endpoint,
service=self.service_name
).observe(duration)
def record_training_job(self, status: str):
"""Record training job metrics"""
TRAINING_JOBS.labels(
status=status,
service=self.service_name
).inc()
def record_forecast_generated(self):
"""Record forecast generation metrics"""
FORECASTS_GENERATED.labels(
service=self.service_name
).inc()
def set_active_connections(self, count: int):
"""Set active database connections"""
ACTIVE_CONNECTIONS.labels(
service=self.service_name
).set(count)
def metrics_middleware(metrics_collector: MetricsCollector):
"""Middleware to collect metrics"""
def middleware(request, call_next):
start_time = time.time()
response = call_next(request)
duration = time.time() - start_time
metrics_collector.record_request(
method=request.method,
endpoint=request.url.path,
status_code=response.status_code,
duration=duration
)
return response
return middleware

View File

View File

@@ -0,0 +1,71 @@
"""
DateTime utilities for microservices
"""
from datetime import datetime, timezone, timedelta
from typing import Optional
import pytz
def utc_now() -> datetime:
"""Get current UTC datetime"""
return datetime.now(timezone.utc)
def madrid_now() -> datetime:
"""Get current Madrid datetime"""
madrid_tz = pytz.timezone('Europe/Madrid')
return datetime.now(madrid_tz)
def to_utc(dt: datetime) -> datetime:
"""Convert datetime to UTC"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_madrid(dt: datetime) -> datetime:
"""Convert datetime to Madrid timezone"""
madrid_tz = pytz.timezone('Europe/Madrid')
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(madrid_tz)
def format_datetime(dt: datetime, format_str: str = "%Y-%m-%d %H:%M:%S") -> str:
"""Format datetime as string"""
return dt.strftime(format_str)
def parse_datetime(dt_str: str, format_str: str = "%Y-%m-%d %H:%M:%S") -> datetime:
"""Parse datetime from string"""
return datetime.strptime(dt_str, format_str)
def is_business_hours(dt: Optional[datetime] = None) -> bool:
"""Check if datetime is during business hours (9 AM - 6 PM Madrid time)"""
if dt is None:
dt = madrid_now()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
madrid_dt = to_madrid(dt)
# Check if it's a weekday (Monday=0, Sunday=6)
if madrid_dt.weekday() >= 5: # Weekend
return False
# Check if it's business hours
return 9 <= madrid_dt.hour < 18
def next_business_day(dt: Optional[datetime] = None) -> datetime:
"""Get next business day"""
if dt is None:
dt = madrid_now()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
madrid_dt = to_madrid(dt)
# Add days until we reach a weekday
while madrid_dt.weekday() >= 5: # Weekend
madrid_dt += timedelta(days=1)
# Set to 9 AM
return madrid_dt.replace(hour=9, minute=0, second=0, microsecond=0)

View File

@@ -0,0 +1,67 @@
"""
Validation utilities for microservices
"""
import re
from typing import Any, Optional
from email_validator import validate_email, EmailNotValidError
def validate_spanish_phone(phone: str) -> bool:
"""Validate Spanish phone number"""
# Spanish phone pattern: +34 followed by 9 digits
pattern = r'^(\+34|0034|34)?[6-9]\d{8}$'
return bool(re.match(pattern, phone.replace(' ', '').replace('-', '')))
def validate_email_address(email: str) -> bool:
"""Validate email address"""
try:
validate_email(email)
return True
except EmailNotValidError:
return False
def validate_tenant_name(name: str) -> bool:
"""Validate tenant name"""
# Must be 2-50 characters, letters, numbers, spaces, hyphens, apostrophes
pattern = r"^[a-zA-ZÀ-ÿ0-9\s\-']{2,50}$"
return bool(re.match(pattern, name))
def validate_address(address: str) -> bool:
"""Validate address"""
# Must be 5-200 characters
return 5 <= len(address.strip()) <= 200
def validate_coordinates(latitude: float, longitude: float) -> bool:
"""Validate Madrid coordinates"""
# Madrid is roughly between these coordinates
madrid_bounds = {
'lat_min': 40.3,
'lat_max': 40.6,
'lon_min': -3.8,
'lon_max': -3.5
}
return (
madrid_bounds['lat_min'] <= latitude <= madrid_bounds['lat_max'] and
madrid_bounds['lon_min'] <= longitude <= madrid_bounds['lon_max']
)
def validate_product_name(name: str) -> bool:
"""Validate product name"""
# Must be 1-50 characters, letters, numbers, spaces
pattern = r"^[a-zA-ZÀ-ÿ0-9\s]{1,50}$"
return bool(re.match(pattern, name))
def validate_positive_number(value: Any) -> bool:
"""Validate positive number"""
try:
return float(value) > 0
except (ValueError, TypeError):
return False
def validate_non_negative_number(value: Any) -> bool:
"""Validate non-negative number"""
try:
return float(value) >= 0
except (ValueError, TypeError):
return False

View File

@@ -0,0 +1,31 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

View File

View File

@@ -0,0 +1,32 @@
"""
uLuforecasting service configuration
"""
import os
from pydantic import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
# Basic settings
APP_NAME: str = "uLuforecasting Service"
VERSION: str = "1.0.0"
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# Database settings
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://forecasting_user:forecasting_pass123@forecasting-db:5432/forecasting_db")
# Redis settings
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
# RabbitMQ settings
RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://bakery:forecast123@rabbitmq:5672/")
# Service URLs
AUTH_SERVICE_URL: str = os.getenv("AUTH_SERVICE_URL", "http://auth-service:8000")
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,12 @@
"""
Database configuration for forecasting service
"""
from shared.database.base import DatabaseManager
from app.core.config import settings
# Initialize database manager
database_manager = DatabaseManager(settings.DATABASE_URL)
# Alias for convenience
get_db = database_manager.get_db

View File

@@ -0,0 +1,61 @@
"""
uLuforecasting Service
"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import database_manager
from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector
# Setup logging
setup_logging("forecasting-service", "INFO")
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="uLuforecasting Service",
description="uLuforecasting service for bakery forecasting",
version="1.0.0"
)
# Initialize metrics collector
metrics_collector = MetricsCollector("forecasting-service")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup_event():
"""Application startup"""
logger.info("Starting uLuforecasting Service")
# Create database tables
await database_manager.create_tables()
# Start metrics server
metrics_collector.start_metrics_server(8080)
logger.info("uLuforecasting Service started successfully")
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "forecasting-service",
"version": "1.0.0"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,13 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
asyncpg==0.29.0
alembic==1.12.1
pydantic==2.5.0
pydantic-settings==2.1.0
httpx==0.25.2
redis==5.0.1
aio-pika==9.3.0
prometheus-client==0.17.1
python-json-logger==2.0.4
pytz==2023.3

View File

@@ -0,0 +1,41 @@
"""
Authentication decorators for FastAPI
"""
from functools import wraps
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer
import httpx
import logging
logger = logging.getLogger(__name__)
security = HTTPBearer()
def verify_service_token(auth_service_url: str):
"""Verify service token with auth service"""
async def verify_token(token: str = Depends(security)):
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{auth_service_url}/verify",
headers={"Authorization": f"Bearer {token.credentials}"}
)
if response.status_code == 200:
return response.json()
else:
raise HTTPException(
status_code=401,
detail="Invalid authentication credentials"
)
except httpx.RequestError as e:
logger.error(f"Auth service unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
return verify_token

View File

@@ -0,0 +1,58 @@
"""
Shared JWT Authentication Handler
Used across all microservices for consistent authentication
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
class JWTHandler:
"""JWT token handling for microservices"""
def __init__(self, secret_key: str, algorithm: str = "HS256"):
self.secret_key = secret_key
self.algorithm = algorithm
def create_access_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=30)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def create_refresh_token(self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT refresh token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=7)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
return encoded_jwt
def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify and decode JWT token"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
return payload
except jwt.ExpiredSignatureError:
logger.warning("Token has expired")
return None
except jwt.InvalidTokenError:
logger.warning("Invalid token")
return None

View File

@@ -0,0 +1,56 @@
"""
Base database configuration for all microservices
"""
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.pool import StaticPool
import logging
logger = logging.getLogger(__name__)
Base = declarative_base()
class DatabaseManager:
"""Database manager for microservices"""
def __init__(self, database_url: str):
self.database_url = database_url
self.async_engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_recycle=300,
pool_size=20,
max_overflow=30
)
self.async_session_local = sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db(self):
"""Get database session"""
async with self.async_session_local() as session:
try:
yield session
except Exception as e:
logger.error(f"Database session error: {e}")
await session.rollback()
raise
finally:
await session.close()
async def create_tables(self):
"""Create database tables"""
async with self.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_tables(self):
"""Drop database tables"""
async with self.async_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)

Some files were not shown because too many files have changed in this diff Show More