Restructure with Turborepo
Run Tests on Branches / Backend Tests (push) Successful in 3m38s
Run Tests on Branches / Frontend Tests (push) Successful in 3m28s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 15:51:53 +02:00
parent f66cafbbc3
commit 190b3d163d
277 changed files with 53253 additions and 0 deletions
@@ -0,0 +1,17 @@
from .base_connector import (
BaseInventoryConnector,
ConnectorResult,
StockInfo,
PharmacyInfo,
SyncResult,
ReservationResult,
)
__all__ = [
"BaseInventoryConnector",
"ConnectorResult",
"StockInfo",
"PharmacyInfo",
"SyncResult",
"ReservationResult",
]
@@ -0,0 +1,78 @@
from abc import ABC, abstractmethod
from typing import Any
from uuid import UUID
from pydantic import BaseModel
class ConnectorResult(BaseModel):
success: bool
data: Any | None = None
error: str | None = None
metadata: dict | None = None
class StockInfo(BaseModel):
medication_id: UUID
nregistro: str
name: str
stock: int
reserved_stock: int = 0
available_stock: int = 0
price: float | None = None
last_updated: str | None = None
class PharmacyInfo(BaseModel):
pharmacy_id: UUID
code: str
name: str
address: str | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
latitude: float | None = None
longitude: float | None = None
class SyncResult(BaseModel):
success: bool
items_total: int = 0
items_processed: int = 0
items_failed: int = 0
error_message: str | None = None
metadata: dict | None = None
class ReservationResult(BaseModel):
success: bool
reservation_id: str | None = None
error: str | None = None
class BaseInventoryConnector(ABC):
connector_type: str = "base"
@abstractmethod
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
...
@abstractmethod
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
...
@abstractmethod
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
...
@@ -0,0 +1,3 @@
from src.connectors.farmatic.farmatic_connector import FarmaticConnector
__all__ = ["FarmaticConnector"]
@@ -0,0 +1,122 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from src.connectors.sdk.base_sdk import SDKConnector
from src.connectors.sdk.context import ConnectorContext
from src.connectors.base import ConnectorResult
from src.core.exceptions import ConnectorException
from src.core.logging import get_logger
from src.core.metrics import CONNECTOR_OPERATIONS
logger = get_logger("farmatic_connector")
class FarmaticConnector(SDKConnector):
connector_type: str = "farmatic"
def configure(self, config: dict[str, Any]) -> None:
self._base_url = config.get("base_url", "https://erp.farmatic.local/api/v1")
self._timeout = config.get("timeout", 45.0)
super().configure(config)
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
try:
params: dict[str, Any] = {"pharmacy": str(pharmacy_id)}
if nregistro:
params["nregistro"] = nregistro
if medication_id:
params["medication_id"] = str(medication_id)
response = await self._request("GET", "/stock", params=params)
data = response.json()
self._record_metric("get_stock", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("get_stock", "error")
logger.error("farmatic_get_stock_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
try:
payload: dict[str, Any] = {
"pharmacy_id": str(pharmacy_id),
"medication_id": str(medication_id),
"quantity": quantity,
}
if idempotency_key:
payload["idempotency_key"] = idempotency_key
response = await self._request("POST", "/reservations", json=payload)
data = response.json()
self._record_metric("reserve", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("reserve", "error")
logger.error("farmatic_reserve_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
try:
response = await self._request("DELETE", f"/reservations/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
data = response.json()
self._record_metric("cancel_reservation", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("cancel_reservation", "error")
logger.error("farmatic_cancel_error", extra={"reservation_id": reservation_id, "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
try:
response = await self._request("GET", f"/pharmacies/{pharmacy_id}")
data = response.json()
self._record_metric("pharmacy_information", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("pharmacy_information", "error")
logger.error("farmatic_pharmacy_info_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
try:
payload: dict[str, Any] = {
"pharmacy_id": str(pharmacy_id),
"strategy": strategy,
}
if since:
payload["since"] = since
response = await self._request("POST", "/sync", json=payload)
data = response.json()
self._record_metric("synchronize", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("synchronize", "error")
logger.error("farmatic_sync_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
return ConnectorResult(success=False, error=str(exc))
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
try:
response = await self._request("GET", "/health")
data = response.json()
data["connector_type"] = self.connector_type
data["pharmacy_id"] = str(pharmacy_id)
data["circuit_breaker"] = self._circuit_breaker.state.value
self._record_metric("heartbeat", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("heartbeat", "error")
return ConnectorResult(
success=False,
error=str(exc),
metadata={"circuit_breaker": self._circuit_breaker.state.value},
)
@@ -0,0 +1,3 @@
from .mock_connector import MockConnector
__all__ = ["MockConnector"]
@@ -0,0 +1,215 @@
import asyncio
import random
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from src.connectors.base import BaseInventoryConnector, ConnectorResult
from src.core.logging import get_logger
from src.core.metrics import CONNECTOR_OPERATIONS
logger = get_logger("mock_connector")
class MockConnector(BaseInventoryConnector):
connector_type: str = "mock"
def __init__(self) -> None:
self._simulate_latency: bool = True
self._latency_range_ms: tuple[int, int] = (20, 150)
self._error_rate: float = 0.0
self._stock_data: dict[str, dict[str, Any]] = {}
self._reservations: dict[str, dict[str, Any]] = {}
self._pharmacies: dict[str, dict[str, Any]] = {}
self._init_mock_data()
def configure(self, config: dict[str, Any]) -> None:
self._simulate_latency = config.get("simulate_latency", True)
self._latency_range_ms = tuple(config.get("latency_range_ms", [20, 150]))
self._error_rate = config.get("error_rate", 0.0)
def _init_mock_data(self) -> None:
medicamentos = [
("00001", "Ibuprofeno 600mg", 100),
("00002", "Paracetamol 1g", 50),
("00003", "Amoxicilina 500mg", 200),
("00004", "Omeprazol 20mg", 75),
("00005", "Loratadina 10mg", 120),
("00006", "Diazepam 5mg", 30),
("00007", "Metformina 850mg", 250),
("00008", "Atorvastatina 20mg", 80),
("00009", "Salbutamol Inhalador", 15),
("00010", "Insulina Glargina", 40),
]
for nregistro, name, stock in medicamentos:
reserved = random.randint(0, max(stock // 10, 1))
self._stock_data[nregistro] = {
"nregistro": nregistro,
"name": name,
"stock": stock,
"reserved_stock": reserved,
"available_stock": stock - reserved,
"price": round(random.uniform(2.5, 45.0), 2),
"last_updated": datetime.now(timezone.utc).isoformat(),
}
self._pharmacies["default"] = {
"code": "FARM-001",
"name": "Farmacia Mock Central",
"address": "Calle Mayor 15, Madrid",
"phone": "+34912345678",
"email": "central@mock-farmacia.es",
"opening_hours": {
"mon_fri": ["09:00", "21:30"],
"sat": ["10:00", "14:00"],
"sun": "closed",
},
"latitude": 40.4168,
"longitude": -3.7038,
}
self._pharmacies["second"] = {
"code": "FARM-002",
"name": "Farmacia Mock Norte",
"address": "Av. de la Ilustración 1, Madrid",
"phone": "+34915432100",
"email": "norte@mock-farmacia.es",
"opening_hours": {
"mon_fri": ["08:30", "22:00"],
"sat": ["09:00", "22:00"],
"sun": ["10:00", "14:00"],
},
"latitude": 40.4722,
"longitude": -3.6934,
}
async def _maybe_delay(self) -> None:
if self._simulate_latency:
low, high = self._latency_range_ms
delay = random.randint(low, high) / 1000.0
await asyncio.sleep(delay)
def _maybe_error(self) -> None:
if self._error_rate > 0 and random.random() < self._error_rate:
error_types = [
("TIMEOUT", "Connection timed out"),
("CONNECTION_REFUSED", "ERP server refused connection"),
("AUTH_FAILED", "Authentication failed"),
("RATE_LIMITED", "ERP rate limit exceeded"),
("INTERNAL_ERROR", "ERP internal server error"),
]
code, msg = random.choice(error_types)
from src.core.exceptions import ConnectorException
raise ConnectorException(self.connector_type, msg, details={"error_code": code})
def _metric(self, operation: str, status: str) -> None:
CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_get_stock", extra={"pharmacy_id": str(pharmacy_id), "nregistro": nregistro})
if nregistro and nregistro in self._stock_data:
data = dict(self._stock_data[nregistro])
data["pharmacy_id"] = str(pharmacy_id)
self._metric("get_stock", "success")
return ConnectorResult(success=True, data=data)
if nregistro is None and medication_id is None:
results = [dict(v) for v in self._stock_data.values()]
for r in results:
r["pharmacy_id"] = str(pharmacy_id)
self._metric("get_stock", "success")
return ConnectorResult(success=True, data={"items": results, "total": len(results)})
self._metric("get_stock", "not_found")
return ConnectorResult(success=False, error=f"Medication '{nregistro}' not found in mock data")
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_reserve", extra={"pharmacy_id": str(pharmacy_id), "quantity": quantity})
if idempotency_key and idempotency_key in self._reservations:
self._metric("reserve", "idempotent")
return ConnectorResult(success=True, data=self._reservations[idempotency_key])
reservation_id = f"mock-res-{idempotency_key or random.randint(100000, 999999)}"
reservation_data = {
"reservation_id": reservation_id,
"status": "confirmed",
"quantity": quantity,
"pharmacy_id": str(pharmacy_id),
"medication_id": str(medication_id),
"confirmed_at": datetime.now(timezone.utc).isoformat(),
"expires_at": datetime.now(timezone.utc).isoformat(),
}
self._reservations[reservation_id] = reservation_data
if idempotency_key:
self._reservations[idempotency_key] = reservation_data
self._metric("reserve", "success")
return ConnectorResult(success=True, data=reservation_data)
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_cancel_reservation", extra={"reservation_id": reservation_id})
if reservation_id in self._reservations:
self._reservations[reservation_id]["status"] = "cancelled"
self._metric("cancel_reservation", "success")
return ConnectorResult(success=True, data={"reservation_id": reservation_id, "status": "cancelled"})
self._metric("cancel_reservation", "not_found")
return ConnectorResult(success=False, error=f"Reservation '{reservation_id}' not found")
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_pharmacy_information", extra={"pharmacy_id": str(pharmacy_id)})
pharmacy = list(self._pharmacies.values())[0]
result = dict(pharmacy)
result["pharmacy_id"] = str(pharmacy_id)
self._metric("pharmacy_information", "success")
return ConnectorResult(success=True, data=result)
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
await self._maybe_delay()
self._maybe_error()
logger.info("mock_synchronize", extra={"pharmacy_id": str(pharmacy_id), "strategy": strategy, "since": since})
items_total = len(self._stock_data)
items_processed = items_total
items_failed = random.randint(0, min(2, items_total)) if self._error_rate > 0 else 0
if items_failed > 0:
items_processed = items_total - items_failed
self._metric("synchronize", "success")
return ConnectorResult(
success=True,
data={
"items_total": items_total,
"items_processed": items_processed,
"items_failed": items_failed,
"strategy": strategy,
"synced_at": datetime.now(timezone.utc).isoformat(),
"pharmacy_id": str(pharmacy_id),
},
)
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
self._metric("heartbeat", "success")
return ConnectorResult(
success=True,
data={
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
"connector_type": self.connector_type,
"pharmacy_id": str(pharmacy_id),
"circuit_breaker": "closed",
"stock_items": len(self._stock_data),
"active_reservations": len([r for r in self._reservations.values() if r.get("status") == "confirmed"]),
},
)
@@ -0,0 +1,105 @@
from __future__ import annotations
import importlib
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from src.connectors.base import BaseInventoryConnector, ConnectorResult
from src.core.exceptions import ConnectorException
from src.core.logging import get_logger
from src.core.metrics import CONNECTOR_OPERATIONS
from src.domain.entities import ConnectorStatus, ERPType
from src.infrastructure.cache.redis_cache import redis_cache
from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
logger = get_logger("connector_registry")
_BUILTIN_CONNECTORS: dict[str, str] = {
ERPType.MOCK.value: "src.connectors.mock.mock_connector.MockConnector",
ERPType.FARMATIC.value: "src.connectors.farmatic.farmatic_connector.FarmaticConnector",
ERPType.CUSTOM_REST.value: "src.connectors.rest.rest_connector.RESTConnector",
}
class ConnectorRegistry:
_instance: ConnectorRegistry | None = None
_connectors: dict[str, BaseInventoryConnector]
_instances: dict[UUID, BaseInventoryConnector]
def __init__(self) -> None:
self._connectors = {}
self._instances = {}
@classmethod
def get_registry(cls) -> ConnectorRegistry:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def register(self, connector_type: str, connector: BaseInventoryConnector) -> None:
self._connectors[connector_type] = connector
logger.info("connector_registered", extra={"connector_type": connector_type})
def unregister(self, connector_type: str) -> None:
self._connectors.pop(connector_type, None)
logger.info("connector_unregistered", extra={"connector_type": connector_type})
def get_connector_class(self, connector_type: str) -> type[BaseInventoryConnector]:
if connector_type in self._connectors:
return type(self._connectors[connector_type])
class_path = _BUILTIN_CONNECTORS.get(connector_type)
if not class_path:
raise ConnectorException(connector_type, f"No connector registered for type '{connector_type}'")
try:
module_path, class_name = class_path.rsplit(".", 1)
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
self._connectors[connector_type] = cls()
return cls
except (ImportError, AttributeError) as e:
raise ConnectorException(connector_type, f"Failed to load connector '{connector_type}': {e}") from e
def create_instance(self, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
cls = self.get_connector_class(connector_type)
instance = cls()
if config and hasattr(instance, "configure"):
instance.configure(config)
return instance
def get_or_create_instance(self, connector_id: UUID, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
if connector_id in self._instances:
return self._instances[connector_id]
instance = self.create_instance(connector_type, config)
self._instances[connector_id] = instance
return instance
def remove_instance(self, connector_id: UUID) -> None:
self._instances.pop(connector_id, None)
def list_registered_types(self) -> list[str]:
all_types = set(self._connectors.keys()) | set(_BUILTIN_CONNECTORS.keys())
return sorted(all_types)
@staticmethod
async def record_heartbeat(repo: ConnectorRepository, connector_id: UUID) -> None:
entity = await repo.get_by_id(connector_id)
if entity:
entity.last_heartbeat_at = datetime.now(timezone.utc)
entity.status = ConnectorStatus.ACTIVE
entity.last_error = None
await repo.update(entity)
@staticmethod
async def record_error(repo: ConnectorRepository, connector_id: UUID, error: str) -> None:
entity = await repo.get_by_id(connector_id)
if entity:
entity.last_error = error
entity.status = ConnectorStatus.ERROR
await repo.update(entity)
connector_registry = ConnectorRegistry.get_registry()
@@ -0,0 +1,3 @@
from src.connectors.rest.rest_connector import RESTConnector
__all__ = ["RESTConnector"]
@@ -0,0 +1,103 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from src.connectors.sdk.base_sdk import SDKConnector
from src.connectors.base import ConnectorResult
from src.core.logging import get_logger
logger = get_logger("rest_connector")
class RESTConnector(SDKConnector):
connector_type: str = "custom_rest"
_STOCK_PATH: str = "/stock"
_RESERVE_PATH: str = "/reservations"
_PHARMACY_PATH: str = "/pharmacy"
_SYNC_PATH: str = "/sync"
_HEALTH_PATH: str = "/health"
def configure(self, config: dict[str, Any]) -> None:
self._base_url = config.get("base_url", "")
self._timeout = config.get("timeout", 30.0)
self._STOCK_PATH = config.get("stock_path", self._STOCK_PATH)
self._RESERVE_PATH = config.get("reserve_path", self._RESERVE_PATH)
self._PHARMACY_PATH = config.get("pharmacy_path", self._PHARMACY_PATH)
self._SYNC_PATH = config.get("sync_path", self._SYNC_PATH)
self._HEALTH_PATH = config.get("health_path", self._HEALTH_PATH)
super().configure(config)
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
try:
params: dict[str, Any] = {"pharmacy_id": str(pharmacy_id)}
if nregistro:
params["nregistro"] = nregistro
if medication_id:
params["medication_id"] = str(medication_id)
response = await self._request("GET", self._STOCK_PATH, params=params)
self._record_metric("get_stock", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("get_stock", "error")
return ConnectorResult(success=False, error=str(exc))
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
try:
payload: dict[str, Any] = {
"pharmacy_id": str(pharmacy_id),
"medication_id": str(medication_id),
"quantity": quantity,
}
if idempotency_key:
payload["idempotency_key"] = idempotency_key
response = await self._request("POST", self._RESERVE_PATH, json=payload)
self._record_metric("reserve", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("reserve", "error")
return ConnectorResult(success=False, error=str(exc))
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
try:
response = await self._request("DELETE", f"{self._RESERVE_PATH}/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
self._record_metric("cancel_reservation", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("cancel_reservation", "error")
return ConnectorResult(success=False, error=str(exc))
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
try:
response = await self._request("GET", f"{self._PHARMACY_PATH}/{pharmacy_id}")
self._record_metric("pharmacy_information", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("pharmacy_information", "error")
return ConnectorResult(success=False, error=str(exc))
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
try:
payload: dict[str, Any] = {"pharmacy_id": str(pharmacy_id), "strategy": strategy}
if since:
payload["since"] = since
response = await self._request("POST", self._SYNC_PATH, json=payload)
self._record_metric("synchronize", "success")
return ConnectorResult(success=True, data=response.json())
except Exception as exc:
self._record_metric("synchronize", "error")
return ConnectorResult(success=False, error=str(exc))
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
try:
response = await self._request("GET", self._HEALTH_PATH)
data = response.json()
data["connector_type"] = self.connector_type
data["pharmacy_id"] = str(pharmacy_id)
data["circuit_breaker"] = self._circuit_breaker.state.value
self._record_metric("heartbeat", "success")
return ConnectorResult(success=True, data=data)
except Exception as exc:
self._record_metric("heartbeat", "error")
return ConnectorResult(success=False, error=str(exc))
@@ -0,0 +1,19 @@
from .context import ConnectorContext
from .auth import AuthHandler, BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler, OAuth2Handler
from .retry import RetryPolicy, with_retry
from .circuit_breaker import CircuitBreaker, CircuitState
from .base_sdk import SDKConnector
__all__ = [
"APIKeyAuthHandler",
"AuthHandler",
"BasicAuthHandler",
"BearerAuthHandler",
"CircuitBreaker",
"CircuitState",
"ConnectorContext",
"OAuth2Handler",
"RetryPolicy",
"SDKConnector",
"with_retry",
]
@@ -0,0 +1,123 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
import httpx
from src.connectors.sdk.context import ConnectorContext
from src.core.logging import get_logger
logger = get_logger("connector_auth")
class AuthHandler(ABC):
@abstractmethod
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
...
@abstractmethod
async def refresh(self, context: ConnectorContext) -> bool:
return True
class BasicAuthHandler(AuthHandler):
def __init__(self, username_key: str = "username", password_key: str = "password") -> None:
self._username_key = username_key
self._password_key = password_key
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
import base64
username = context.get_secret(self._username_key) or context.get_config(self._username_key, "")
password = context.get_secret(self._password_key) or context.get_config(self._password_key, "")
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
request.headers["Authorization"] = f"Basic {credentials}"
return request
async def refresh(self, context: ConnectorContext) -> bool:
return True
class BearerAuthHandler(AuthHandler):
def __init__(self, token_key: str = "access_token") -> None:
self._token_key = token_key
self._token: str | None = None
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
token = self._token or context.get_secret(self._token_key) or context.get_config(self._token_key, "")
if token:
request.headers["Authorization"] = f"Bearer {token}"
return request
async def refresh(self, context: ConnectorContext) -> bool:
self._token = None
return True
class APIKeyAuthHandler(AuthHandler):
def __init__(self, header_name: str = "X-API-Key", key_secret: str = "api_key") -> None:
self._header_name = header_name
self._key_secret = key_secret
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
api_key = context.get_secret(self._key_secret) or context.get_config(self._key_secret, "")
if api_key:
request.headers[self._header_name] = api_key
return request
async def refresh(self, context: ConnectorContext) -> bool:
return True
class OAuth2Handler(AuthHandler):
def __init__(
self,
token_url_key: str = "token_url",
client_id_key: str = "client_id",
client_secret_key: str = "client_secret",
) -> None:
self._token_url_key = token_url_key
self._client_id_key = client_id_key
self._client_secret_key = client_secret_key
self._access_token: str | None = None
self._token_expires: float = 0
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
import time
if not self._access_token or time.time() >= self._token_expires:
await self._fetch_token(context)
if self._access_token:
request.headers["Authorization"] = f"Bearer {self._access_token}"
return request
async def _fetch_token(self, context: ConnectorContext) -> None:
import time
token_url = context.require_config(self._token_url_key)
client_id = context.require_secret(self._client_id_key)
client_secret = context.require_secret(self._client_secret_key)
async with httpx.AsyncClient() as client:
resp = await client.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
data = resp.json()
self._access_token = data["access_token"]
self._token_expires = time.time() + data.get("expires_in", 3600) - 30
logger.info("oauth2_token_acquired", extra={"token_url": token_url})
async def refresh(self, context: ConnectorContext) -> bool:
self._access_token = None
self._token_expires = 0
return True
@@ -0,0 +1,137 @@
from __future__ import annotations
from abc import abstractmethod
from typing import Any
from uuid import UUID
import httpx
from src.connectors.base import BaseInventoryConnector, ConnectorResult
from src.connectors.sdk.auth import AuthHandler, BearerAuthHandler
from src.connectors.sdk.circuit_breaker import CircuitBreaker
from src.connectors.sdk.context import ConnectorContext
from src.connectors.sdk.retry import RetryPolicy, with_retry
from src.core.exceptions import ConnectorException
from src.core.logging import get_logger
from src.core.metrics import CONNECTOR_OPERATIONS
logger = get_logger("sdk_connector")
class SDKConnector(BaseInventoryConnector):
connector_type: str = "sdk"
def __init__(self) -> None:
self._context: ConnectorContext | None = None
self._auth_handler: AuthHandler = BearerAuthHandler()
self._retry_policy: RetryPolicy = RetryPolicy()
self._circuit_breaker: CircuitBreaker = CircuitBreaker(name=self.connector_type)
self._http_client: httpx.AsyncClient | None = None
self._base_url: str = ""
self._timeout: float = 30.0
def configure(self, config: dict[str, Any]) -> None:
self._base_url = config.get("base_url", "")
self._timeout = config.get("timeout", 30.0)
auth_type = config.get("auth_type", "bearer")
if auth_type == "basic":
from src.connectors.sdk.auth import BasicAuthHandler
self._auth_handler = BasicAuthHandler()
elif auth_type == "api_key":
from src.connectors.sdk.auth import APIKeyAuthHandler
self._auth_handler = APIKeyAuthHandler(
header_name=config.get("api_key_header", "X-API-Key"),
key_secret=config.get("api_key_secret_key", "api_key"),
)
elif auth_type == "oauth2":
from src.connectors.sdk.auth import OAuth2Handler
self._auth_handler = OAuth2Handler()
cb_config = config.get("circuit_breaker", {})
if cb_config:
self._circuit_breaker = CircuitBreaker(
name=self.connector_type,
failure_threshold=cb_config.get("failure_threshold", 5),
recovery_timeout=cb_config.get("recovery_timeout", 30.0),
)
retry_config = config.get("retry", {})
if retry_config:
self._retry_policy = RetryPolicy(
max_attempts=retry_config.get("max_attempts", 3),
base_delay=retry_config.get("base_delay", 1.0),
max_delay=retry_config.get("max_delay", 30.0),
)
def set_context(self, context: ConnectorContext) -> None:
self._context = context
if context.config:
self.configure(context.config)
@property
def http_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
base_url=self._base_url,
timeout=self._timeout,
)
return self._http_client
async def _request(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
if not self._context:
raise ConnectorException(self.connector_type, "Connector context not set. Call set_context() first.")
async def _do_request() -> httpx.Response:
request = self.http_client.build_request(method, path, json=json, params=params, headers=headers)
request = await self._auth_handler.apply(request, self._context)
response = await self.http_client.send(request)
response.raise_for_status()
return response
return await self._circuit_breaker.call(
with_retry,
_do_request,
policy=self._retry_policy,
connector_type=self.connector_type,
operation=f"{method}:{path}",
)
def _record_metric(self, operation: str, status: str) -> None:
CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
async def close(self) -> None:
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
@abstractmethod
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
...
@abstractmethod
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
...
@abstractmethod
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
...
@abstractmethod
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
...
@@ -0,0 +1,106 @@
from __future__ import annotations
import time
from enum import Enum
from typing import Any, Callable
from src.core.exceptions import ConnectorException, ServiceUnavailableException
from src.core.logging import get_logger
logger = get_logger("circuit_breaker")
class CircuitState(str, Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
def __init__(
self,
name: str,
failure_threshold: int = 5,
recovery_timeout: float = 30.0,
half_open_max_calls: int = 1,
) -> None:
self._name = name
self._failure_threshold = failure_threshold
self._recovery_timeout = recovery_timeout
self._half_open_max_calls = half_open_max_calls
self._state = CircuitState.CLOSED
self._failure_count = 0
self._success_count = 0
self._last_failure_time: float = 0
self._half_open_calls: int = 0
@property
def state(self) -> CircuitState:
if self._state == CircuitState.OPEN:
if time.monotonic() - self._last_failure_time >= self._recovery_timeout:
self._state = CircuitState.HALF_OPEN
self._half_open_calls = 0
logger.info("circuit_half_open", extra={"name": self._name})
return self._state
async def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
current_state = self.state
if current_state == CircuitState.OPEN:
raise ServiceUnavailableException(
f"circuit_breaker:{self._name}",
f"Circuit breaker '{self._name}' is open. Retry after {self._recovery_timeout}s.",
)
if current_state == CircuitState.HALF_OPEN:
if self._half_open_calls >= self._half_open_max_calls:
raise ServiceUnavailableException(
f"circuit_breaker:{self._name}",
f"Circuit breaker '{self._name}' is half-open with max concurrent calls reached.",
)
self._half_open_calls += 1
try:
result = await func(*args, **kwargs)
self._on_success()
return result
except Exception as exc:
self._on_failure(exc)
raise
def _on_success(self) -> None:
if self._state == CircuitState.HALF_OPEN:
self._state = CircuitState.CLOSED
logger.info("circuit_closed", extra={"name": self._name})
self._failure_count = 0
self._success_count += 1
def _on_failure(self, exc: Exception) -> None:
self._failure_count += 1
self._last_failure_time = time.monotonic()
self._success_count = 0
if self._state == CircuitState.HALF_OPEN:
self._state = CircuitState.OPEN
logger.warning("circuit_reopened", extra={"name": self._name, "error": str(exc)})
elif self._failure_count >= self._failure_threshold:
self._state = CircuitState.OPEN
logger.warning("circuit_opened", extra={"name": self._name, "failure_count": self._failure_count})
def reset(self) -> None:
self._state = CircuitState.CLOSED
self._failure_count = 0
self._success_count = 0
self._last_failure_time = 0
def get_status(self) -> dict[str, Any]:
return {
"name": self._name,
"state": self.state.value,
"failure_count": self._failure_count,
"success_count": self._success_count,
"failure_threshold": self._failure_threshold,
"recovery_timeout": self._recovery_timeout,
"last_failure_time": self._last_failure_time,
}
@@ -0,0 +1,42 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID
@dataclass
class ConnectorContext:
connector_id: UUID
pharmacy_id: UUID
erp_system_id: UUID
connector_type: str
config: dict[str, Any] = field(default_factory=dict)
secrets: dict[str, str] = field(default_factory=dict)
_cache_prefix: str = field(default="connector", init=False, repr=False)
@property
def cache_key_prefix(self) -> str:
return f"{self._cache_prefix}:{self.connector_type}:{self.pharmacy_id}"
def cache_key(self, suffix: str) -> str:
return f"{self.cache_key_prefix}:{suffix}"
def get_config(self, key: str, default: Any = None) -> Any:
return self.config.get(key, default)
def get_secret(self, key: str) -> str | None:
return self.secrets.get(key)
def require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if value is None:
raise ValueError(f"Required secret '{key}' not found in connector context")
return value
def require_config(self, key: str) -> Any:
value = self.config.get(key)
if value is None:
raise ValueError(f"Required config '{key}' not found in connector context")
return value
@@ -0,0 +1,87 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from functools import wraps
from typing import Any, Callable, TypeVar
from src.core.exceptions import ConnectorException
from src.core.logging import get_logger
logger = get_logger("connector_retry")
T = TypeVar("T")
DEFAULT_RETRYABLE_EXCEPTIONS = (ConnectionError, TimeoutError, OSError)
@dataclass
class RetryPolicy:
max_attempts: int = 3
base_delay: float = 1.0
max_delay: float = 30.0
exponential_base: float = 2.0
jitter: bool = True
retryable_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: DEFAULT_RETRYABLE_EXCEPTIONS)
def delay_for_attempt(self, attempt: int) -> float:
import random
delay = min(self.base_delay * (self.exponential_base ** (attempt - 1)), self.max_delay)
if self.jitter:
delay = delay * (0.5 + random.random() * 0.5)
return delay
def should_retry(self, exception: Exception) -> bool:
if isinstance(exception, ConnectorException):
return False
return isinstance(exception, self.retryable_exceptions)
async def with_retry(
func: Callable[..., Any],
*args: Any,
policy: RetryPolicy | None = None,
connector_type: str = "unknown",
operation: str = "unknown",
**kwargs: Any,
) -> Any:
p = policy or RetryPolicy()
last_exception: Exception | None = None
for attempt in range(1, p.max_attempts + 1):
try:
result = await func(*args, **kwargs)
if attempt > 1:
logger.info("retry_succeeded", extra={"connector_type": connector_type, "operation": operation, "attempt": attempt})
return result
except Exception as exc:
last_exception = exc
if not p.should_retry(exc) or attempt >= p.max_attempts:
break
delay = p.delay_for_attempt(attempt)
logger.warning(
"retry_attempt",
extra={
"connector_type": connector_type,
"operation": operation,
"attempt": attempt,
"delay_s": round(delay, 2),
"error": str(exc),
},
)
await asyncio.sleep(delay)
raise last_exception # type: ignore[misc]
def retry(policy: RetryPolicy | None = None):
p = policy or RetryPolicy()
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
connector_type = getattr(args[0], "connector_type", "unknown") if args else "unknown"
return await with_retry(func, *args, policy=p, connector_type=connector_type, operation=func.__name__, **kwargs)
return wrapper
return decorator