feat: add raw cdr ingestion endpoint
This commit is contained in:
@@ -5,11 +5,14 @@ description = "High-throughput telecom CDR ingestion and analytics pipeline"
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phonenumbers>=8.13",
|
"phonenumbers>=8.13",
|
||||||
|
"fastapi>=0.115,<1.0",
|
||||||
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
|
"httpx>=0.27,<1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
0
src/core/api/__init__.py
Normal file
0
src/core/api/__init__.py
Normal file
22
src/core/api/main.py
Normal file
22
src/core/api/main.py
Normal file
@@ -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",
|
||||||
|
}
|
||||||
58
src/core/api/routes.py
Normal file
58
src/core/api/routes.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
131
tests/integration/test_ingestion_api.py
Normal file
131
tests/integration/test_ingestion_api.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user