diff --git a/pyproject.toml b/pyproject.toml index b910b62..d89d360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,14 @@ description = "High-throughput telecom CDR ingestion and analytics pipeline" requires-python = ">=3.11" dependencies = [ "phonenumbers>=8.13", + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.30,<1.0", ] [project.optional-dependencies] dev = [ "pytest>=8.0", + "httpx>=0.27,<1.0", ] [build-system] diff --git a/src/core/api/__init__.py b/src/core/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/api/main.py b/src/core/api/main.py new file mode 100644 index 0000000..4884f65 --- /dev/null +++ b/src/core/api/main.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI + +from core.api.routes import router + + +app = FastAPI( + title="Telecom CDR Ingestion API", + version="0.1.0", +) + + +app.include_router( + router, + prefix="/api/v1", +) + + +@app.get("/health") +async def health() -> dict[str, str]: + return { + "status": "ok", + } \ No newline at end of file diff --git a/src/core/api/routes.py b/src/core/api/routes.py new file mode 100644 index 0000000..6ec68ee --- /dev/null +++ b/src/core/api/routes.py @@ -0,0 +1,58 @@ +import json + +from fastapi import APIRouter, Request, status +from fastapi.responses import JSONResponse + +from core.domain.errors import CDRProcessingError +from core.domain.validation import parse_raw_cdr + + +router = APIRouter() + + +@router.post( + "/cdr/raw", + status_code=status.HTTP_202_ACCEPTED, +) +async def ingest_raw_cdr(request: Request) -> JSONResponse: + try: + payload = await request.json() + + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "invalid_json", + "detail": str(exc), + }, + ) + + if not isinstance(payload, dict): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "invalid_payload", + "detail": "JSON payload must be an object.", + }, + ) + + try: + raw_cdr = parse_raw_cdr(payload) + + except CDRProcessingError as exc: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": exc.reason.value, + "detail": exc.detail, + "failed_field": exc.failed_field, + }, + ) + + return JSONResponse( + status_code=status.HTTP_202_ACCEPTED, + content={ + "status": "accepted", + "raw_id": raw_cdr.raw_id, + }, + ) diff --git a/tests/integration/test_ingestion_api.py b/tests/integration/test_ingestion_api.py new file mode 100644 index 0000000..385c013 --- /dev/null +++ b/tests/integration/test_ingestion_api.py @@ -0,0 +1,131 @@ +from fastapi.testclient import TestClient + +from core.api.main import app + + +client = TestClient(app) + + +def valid_payload() -> dict: + return { + "raw_id": "raw-test-001", + "src_ip": "10.240.12.1", + "device_id": "310410998877665", + "caller_num": "0019875551234", + "svc": "4G_DATA", + "up_bytes": 1048576, + "down_bytes": 4194304, + "rat": 6, + "lac": "4021", + "ci": "8812", + "epoch_start": 1789900000, + "epoch_end": 1789900300, + } + + +def test_health_endpoint() -> None: + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + } + + +def test_valid_cdr_returns_202() -> None: + payload = valid_payload() + + response = client.post( + "/api/v1/cdr/raw", + json=payload, + ) + + assert response.status_code == 202 + + assert response.json() == { + "status": "accepted", + "raw_id": "raw-test-001", + } + + +def test_missing_required_field_returns_400() -> None: + payload = valid_payload() + del payload["caller_num"] + + response = client.post( + "/api/v1/cdr/raw", + json=payload, + ) + + assert response.status_code == 400 + + body = response.json() + + assert body["error"] == "missing_required_field" + assert body["failed_field"] == "caller_num" + + +def test_invalid_rat_returns_400() -> None: + payload = valid_payload() + payload["rat"] = 99 + + response = client.post( + "/api/v1/cdr/raw", + json=payload, + ) + + assert response.status_code == 400 + + body = response.json() + + assert body["error"] == "invalid_rat_value" + assert body["failed_field"] == "rat" + + +def test_invalid_epoch_returns_400() -> None: + payload = valid_payload() + + payload["epoch_start"] = 200 + payload["epoch_end"] = 100 + + response = client.post( + "/api/v1/cdr/raw", + json=payload, + ) + + assert response.status_code == 400 + + body = response.json() + + assert body["error"] == "invalid_epoch" + +def test_malformed_json_returns_400() -> None: + response = client.post( + "/api/v1/cdr/raw", + content='{"raw_id": invalid}', + headers={ + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 400 + + body = response.json() + + assert body["error"] == "invalid_json" + +def test_non_object_json_returns_400() -> None: + response = client.post( + "/api/v1/cdr/raw", + json=[ + "not", + "a", + "cdr", + ], + ) + + assert response.status_code == 400 + + body = response.json() + + assert body["error"] == "invalid_payload"