From b8bd410eeb2779702b9e6740c07635b2cf00cabd Mon Sep 17 00:00:00 2001 From: valenti Date: Thu, 16 Apr 2026 12:05:29 -0400 Subject: [PATCH] API client --- fastcheck_api/.env copy.example | 19 ++ fastcheck_api/.env.example | 20 ++ fastcheck_api/README.md | 40 ++++ fastcheck_api/__init__.py | 1 + fastcheck_api/app/__init__.py | 1 + fastcheck_api/app/api/__init__.py | 1 + fastcheck_api/app/api/dependencies.py | 191 ++++++++++++++++++ fastcheck_api/app/api/v1/__init__.py | 1 + fastcheck_api/app/api/v1/routers/__init__.py | 1 + fastcheck_api/app/api/v1/routers/auth.py | 101 +++++++++ fastcheck_api/app/api/v1/routers/checks.py | 169 ++++++++++++++++ fastcheck_api/app/api/v1/routers/reports.py | 37 ++++ fastcheck_api/app/api/v1/routers/usage.py | 59 ++++++ fastcheck_api/app/core/__init__.py | 1 + fastcheck_api/app/core/config.py | 75 +++++++ fastcheck_api/app/core/logging.py | 25 +++ fastcheck_api/app/core/mongodb.py | 28 +++ fastcheck_api/app/core/security.py | 65 ++++++ fastcheck_api/app/main.py | 134 ++++++++++++ fastcheck_api/app/middleware/__init__.py | 1 + .../app/middleware/request_logging.py | 37 ++++ .../app/middleware/tenant_context.py | 11 + fastcheck_api/app/schemas/__init__.py | 1 + fastcheck_api/app/schemas/auth.py | 23 +++ fastcheck_api/app/schemas/checks.py | 57 ++++++ fastcheck_api/app/schemas/common.py | 8 + fastcheck_api/app/schemas/reports.py | 17 ++ fastcheck_api/app/schemas/usage.py | 35 ++++ fastcheck_api/app/services/__init__.py | 1 + fastcheck_api/app/services/audit_service.py | 64 ++++++ fastcheck_api/app/services/auth_service.py | 47 +++++ fastcheck_api/app/services/check_service.py | 135 +++++++++++++ fastcheck_api/app/services/legacy_service.py | 45 +++++ .../app/services/rabbitmq_service.py | 47 +++++ fastcheck_api/app/utils/__init__.py | 1 + fastcheck_api/app/utils/mongo.py | 23 +++ fastcheck_api/app/workers/__init__.py | 1 + fastcheck_api/app/workers/check_worker.py | 148 ++++++++++++++ fastcheck_api/docs/api_reference.md | 19 ++ fastcheck_api/docs/authentication.md | 62 ++++++ fastcheck_api/docs/examples.md | 85 ++++++++ fastcheck_api/docs/index.md | 12 ++ fastcheck_api/docs/quickstart.md | 45 +++++ fastcheck_api/requirements.txt | 11 + fastcheck_api/run.sh | 33 +++ 45 files changed, 1938 insertions(+) create mode 100644 fastcheck_api/.env copy.example create mode 100644 fastcheck_api/.env.example create mode 100644 fastcheck_api/README.md create mode 100644 fastcheck_api/__init__.py create mode 100644 fastcheck_api/app/__init__.py create mode 100644 fastcheck_api/app/api/__init__.py create mode 100644 fastcheck_api/app/api/dependencies.py create mode 100644 fastcheck_api/app/api/v1/__init__.py create mode 100644 fastcheck_api/app/api/v1/routers/__init__.py create mode 100644 fastcheck_api/app/api/v1/routers/auth.py create mode 100644 fastcheck_api/app/api/v1/routers/checks.py create mode 100644 fastcheck_api/app/api/v1/routers/reports.py create mode 100644 fastcheck_api/app/api/v1/routers/usage.py create mode 100644 fastcheck_api/app/core/__init__.py create mode 100644 fastcheck_api/app/core/config.py create mode 100644 fastcheck_api/app/core/logging.py create mode 100644 fastcheck_api/app/core/mongodb.py create mode 100644 fastcheck_api/app/core/security.py create mode 100644 fastcheck_api/app/main.py create mode 100644 fastcheck_api/app/middleware/__init__.py create mode 100644 fastcheck_api/app/middleware/request_logging.py create mode 100644 fastcheck_api/app/middleware/tenant_context.py create mode 100644 fastcheck_api/app/schemas/__init__.py create mode 100644 fastcheck_api/app/schemas/auth.py create mode 100644 fastcheck_api/app/schemas/checks.py create mode 100644 fastcheck_api/app/schemas/common.py create mode 100644 fastcheck_api/app/schemas/reports.py create mode 100644 fastcheck_api/app/schemas/usage.py create mode 100644 fastcheck_api/app/services/__init__.py create mode 100644 fastcheck_api/app/services/audit_service.py create mode 100644 fastcheck_api/app/services/auth_service.py create mode 100644 fastcheck_api/app/services/check_service.py create mode 100644 fastcheck_api/app/services/legacy_service.py create mode 100644 fastcheck_api/app/services/rabbitmq_service.py create mode 100644 fastcheck_api/app/utils/__init__.py create mode 100644 fastcheck_api/app/utils/mongo.py create mode 100644 fastcheck_api/app/workers/__init__.py create mode 100644 fastcheck_api/app/workers/check_worker.py create mode 100644 fastcheck_api/docs/api_reference.md create mode 100644 fastcheck_api/docs/authentication.md create mode 100644 fastcheck_api/docs/examples.md create mode 100644 fastcheck_api/docs/index.md create mode 100644 fastcheck_api/docs/quickstart.md create mode 100644 fastcheck_api/requirements.txt create mode 100755 fastcheck_api/run.sh diff --git a/fastcheck_api/.env copy.example b/fastcheck_api/.env copy.example new file mode 100644 index 0000000..765b982 --- /dev/null +++ b/fastcheck_api/.env copy.example @@ -0,0 +1,19 @@ +MONGODB_URI=mongodb://localhost:27017/duxiter + +VITE_JWT_SECRET=change-me +VITE_JWT_EXPIRES_IN=7d + +FASTCHECK_API_HOST=127.0.0.1 +FASTCHECK_API_PORT=8080 +FASTCHECK_LOG_LEVEL=INFO + +LEGACY_BASE_URL=http://localhost:4040/api +LEGACY_HTTP_TIMEOUT_SECONDS=30 + +RABBITMQ_ENABLED=true +RABBITMQ_HOST=localhost +RABBITMQ_PORT=5672 +RABBITMQ_VHOST=/ +RABBITMQ_USERNAME=guest +RABBITMQ_PASSWORD=guest +RABBITMQ_QUEUE=evaluation_queue diff --git a/fastcheck_api/.env.example b/fastcheck_api/.env.example new file mode 100644 index 0000000..fdaa60a --- /dev/null +++ b/fastcheck_api/.env.example @@ -0,0 +1,20 @@ +MONGODB_URI=mongodb://localhost:27017/duxiter + +VITE_JWT_SECRET=change-me +VITE_JWT_EXPIRES_IN=7d + +FASTCHECK_API_HOST=127.0.0.1 +FASTCHECK_API_PORT=8080 +FASTCHECK_API_PREFIX=/api/v1 +FASTCHECK_LOG_LEVEL=INFO + +LEGACY_BASE_URL=http://localhost:4040/api +LEGACY_HTTP_TIMEOUT_SECONDS=30 + +RABBITMQ_ENABLED=true +RABBITMQ_HOST=localhost +RABBITMQ_PORT=5672 +RABBITMQ_VHOST=/ +RABBITMQ_USERNAME=guest +RABBITMQ_PASSWORD=guest +RABBITMQ_QUEUE=evaluation_queue diff --git a/fastcheck_api/README.md b/fastcheck_api/README.md new file mode 100644 index 0000000..47ac062 --- /dev/null +++ b/fastcheck_api/README.md @@ -0,0 +1,40 @@ +# FastCheck API (FastAPI) + +This is a parallel, client-facing API that reuses the existing MongoDB database and collections used by the Node.js backend. + +## Documentation + +- OpenAPI schema: `GET /openapi.json` +- Customer docs (UI): `/docs` +- Swagger UI (internal): `/internal/docs` +- ReDoc (internal): `/internal/redoc` +- Customer docs (repo): [docs/index.md](file:///home/duxiter/duxiter_intermediate/fastcheck_api/docs/index.md) + +## Local run (no Docker) + +1. Create and activate a virtual environment. +2. Install dependencies: + +```bash +pip install -r fastcheck_api/requirements.txt +``` + +3. Create an env file: + +```bash +cp fastcheck_api/.env.example fastcheck_api/.env +``` + +4. Start the API: + +```bash +export $(cat fastcheck_api/.env | xargs) +uvicorn fastcheck_api.app.main:app --host ${FASTCHECK_API_HOST:-127.0.0.1} --port ${FASTCHECK_API_PORT:-8080} --reload +``` + +5. Start the worker (in a separate shell): + +```bash +export $(cat fastcheck_api/.env | xargs) +python -m fastcheck_api.app.workers.check_worker +``` diff --git a/fastcheck_api/__init__.py b/fastcheck_api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/__init__.py b/fastcheck_api/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/api/__init__.py b/fastcheck_api/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/api/dependencies.py b/fastcheck_api/app/api/dependencies.py new file mode 100644 index 0000000..22ee6a8 --- /dev/null +++ b/fastcheck_api/app/api/dependencies.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated, Any + +from bson import ObjectId +from fastapi import Depends, Header, HTTPException, Request, status +from motor.motor_asyncio import AsyncIOMotorDatabase + +from fastcheck_api.app.core.mongodb import get_db +from fastcheck_api.app.core.security import AuthError, decode_token + + +@dataclass(frozen=True) +class CurrentUser: + id: str + email: str + role: str + tenant: str + + +async def get_current_user( + request: Request, + authorization: Annotated[str | None, Header()] = None, + db: AsyncIOMotorDatabase = Depends(get_db), +) -> CurrentUser: + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No token provided") + + token = authorization.split(" ", 1)[1].strip() + try: + payload = decode_token(token) + except AuthError as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) from e + + user_id = str(payload.get("id") or "") + email = str(payload.get("email") or "") + role = str(payload.get("role") or "") + tenant = str(payload.get("tenant") or "") + if not user_id or not email or not role or not tenant: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + + try: + user_oid = ObjectId(user_id) + except Exception: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user id") + + user_doc = await db["users"].find_one({"_id": user_oid}, projection={"isActive": 1, "tenant": 1}) + if not user_doc or not user_doc.get("isActive", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"message": "Account not activated. Please wait for superadmin approval.", "code": "ACCOUNT_NOT_ACTIVATED"}, + ) + + stored_tenant = user_doc.get("tenant") + stored_tenant_id = str(stored_tenant) if stored_tenant is not None else "" + if stored_tenant_id and stored_tenant_id != tenant: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + + if role != "superuser": + try: + tenant_oid = ObjectId(tenant) + except Exception: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid tenant id") + + tenant_doc = await db["tenants"].find_one({"_id": tenant_oid}, projection={"isActive": 1}) + if not tenant_doc or not tenant_doc.get("isActive", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"message": "Tenant not activated. Please wait for superadmin approval.", "code": "TENANT_NOT_ACTIVATED"}, + ) + + current = CurrentUser(id=user_id, email=email, role=role, tenant=tenant) + request.state.current_user = current + request.state.tenant_id = tenant + return current + + +Permission = str + + +ROLE_PERMISSIONS: dict[str, set[Permission]] = { + "superuser": { + "user:create", + "user:read", + "user:update", + "user:delete", + "evaluation:create", + "evaluation:read", + "evaluation:update", + "evaluation:delete", + "evaluation:bulk", + "company:read", + "company:update", + "rut:lookup", + "rut:read", + "sheriff:logs:read", + "monitoring:create", + "monitoring:read", + "monitoring:update", + "monitoring:delete", + "monitoring:execute", + "tenant:read", + "tenant:update", + "tenant:users:manage", + "admin:dashboard", + "admin:settings", + "admin:logs", + "notification:read", + "notification:create", + }, + "tenant_admin": { + "user:create", + "user:read", + "user:update", + "user:delete", + "evaluation:create", + "evaluation:read", + "evaluation:update", + "evaluation:delete", + "evaluation:bulk", + "company:read", + "company:update", + "rut:lookup", + "rut:read", + "sheriff:logs:read", + "monitoring:create", + "monitoring:read", + "monitoring:update", + "monitoring:delete", + "monitoring:execute", + "tenant:read", + "tenant:update", + "tenant:users:manage", + "admin:dashboard", + "notification:read", + "notification:create", + }, + "evaluator": { + "user:read", + "evaluation:create", + "evaluation:read", + "evaluation:update", + "evaluation:delete", + "evaluation:bulk", + "company:read", + "rut:lookup", + "rut:read", + "sheriff:logs:read", + "monitoring:create", + "monitoring:read", + "monitoring:update", + "monitoring:execute", + "tenant:read", + "notification:read", + }, + "read_only": { + "user:read", + "evaluation:read", + "company:read", + "rut:read", + "sheriff:logs:read", + "monitoring:read", + "tenant:read", + "notification:read", + }, + "write_only": { + "evaluation:create", + "evaluation:read", + "company:read", + "rut:lookup", + "monitoring:create", + "monitoring:execute", + "notification:create", + "tenant:read", + "tenant:update", + }, +} + + +def require_permission(permission: Permission): + async def _dep(user: Annotated[CurrentUser, Depends(get_current_user)]) -> CurrentUser: + perms = ROLE_PERMISSIONS.get(user.role, set()) + if permission not in perms: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"message": "Forbidden: Insufficient permissions", "required": permission, "userRole": user.role}, + ) + return user + + return _dep diff --git a/fastcheck_api/app/api/v1/__init__.py b/fastcheck_api/app/api/v1/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/api/v1/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/api/v1/routers/__init__.py b/fastcheck_api/app/api/v1/routers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/api/v1/routers/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/api/v1/routers/auth.py b/fastcheck_api/app/api/v1/routers/auth.py new file mode 100644 index 0000000..32a8bb9 --- /dev/null +++ b/fastcheck_api/app/api/v1/routers/auth.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Annotated + +from bson import ObjectId +from fastapi import APIRouter, Depends, HTTPException, status +from motor.motor_asyncio import AsyncIOMotorDatabase + +from fastcheck_api.app.api.dependencies import CurrentUser, get_current_user +from fastcheck_api.app.core.config import settings +from fastcheck_api.app.core.mongodb import get_db +from fastcheck_api.app.core.security import create_access_token +from fastcheck_api.app.schemas.auth import LoginRequest, LoginResponse, UserOut +from fastcheck_api.app.services.auth_service import AuthService + + +router = APIRouter(prefix="/auth", tags=["auth"]) +API_PREFIX = settings.normalized_api_prefix or "/api/v1" + + +@router.post( + "/login", + response_model=LoginResponse, + summary="Login", + description="Authenticate with email and password and receive a bearer JWT token compatible with the existing Node.js backend.", + openapi_extra={ + "x-code-samples": [ + { + "lang": "curl", + "label": "cURL", + "source": f"curl -X POST 'http://127.0.0.1:8181{API_PREFIX}/auth/login' \\\n -H 'Content-Type: application/json' \\\n -d '{{\"email\":\"user@example.com\",\"password\":\"********\"}}'", + }, + { + "lang": "python", + "label": "httpx", + "source": f"import httpx\nresp = httpx.post('http://127.0.0.1:8181{API_PREFIX}/auth/login', json={{\n 'email': 'user@example.com',\n 'password': '********'\n}})\nprint(resp.json())", + }, + { + "lang": "js", + "label": "fetch", + "source": f"const resp = await fetch('http://127.0.0.1:8181{API_PREFIX}/auth/login', {{\n method: 'POST',\n headers: {{ 'Content-Type': 'application/json' }},\n body: JSON.stringify({{ email: 'user@example.com', password: '********' }})\n}});\nconsole.log(await resp.json());", + }, + ] + }, +) +async def login(payload: LoginRequest, db: Annotated[AsyncIOMotorDatabase, Depends(get_db)]): + try: + res = await AuthService.login(db=db, email=payload.email, password=payload.password) + return res + except PermissionError as e: + if str(e) == "ACCOUNT_NOT_ACTIVATED": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "message": "Tu cuenta está pendiente de activación por parte del superadministrador. Contacta al administrador para completar la activación.", + "activationStatus": "pending", + }, + ) + raise + except ValueError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + +@router.get( + "/me", + response_model=UserOut, + summary="Get current user", + description="Return the authenticated user's profile from the existing `users` collection.", +) +async def me( + user: Annotated[CurrentUser, Depends(get_current_user)], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], +): + doc = await db["users"].find_one({"_id": ObjectId(user.id)}, projection={"name": 1, "email": 1, "role": 1}) + if not doc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return {"id": user.id, "name": doc.get("name") or "", "email": doc.get("email") or user.email, "role": doc.get("role") or user.role} + + +@router.post( + "/refresh", + summary="Refresh token", + description="Issue a new JWT token using the current token's identity and tenant context.", + openapi_extra={ + "x-code-samples": [ + { + "lang": "curl", + "label": "cURL", + "source": f"curl -X POST 'http://127.0.0.1:8181{API_PREFIX}/auth/refresh' \\\n -H 'Authorization: Bearer '", + }, + { + "lang": "python", + "label": "httpx", + "source": f"import httpx\nresp = httpx.post('http://127.0.0.1:8181{API_PREFIX}/auth/refresh', headers={{\n 'Authorization': 'Bearer '\n}})\nprint(resp.json())", + }, + ] + }, +) +async def refresh(user: Annotated[CurrentUser, Depends(get_current_user)]): + token = create_access_token(user_id=user.id, email=user.email, role=user.role, tenant_id=user.tenant) + return {"token": token} diff --git a/fastcheck_api/app/api/v1/routers/checks.py b/fastcheck_api/app/api/v1/routers/checks.py new file mode 100644 index 0000000..15bf0d9 --- /dev/null +++ b/fastcheck_api/app/api/v1/routers/checks.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import time +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from motor.motor_asyncio import AsyncIOMotorDatabase + +from fastcheck_api.app.api.dependencies import CurrentUser, require_permission +from fastcheck_api.app.core.config import settings +from fastcheck_api.app.core.mongodb import get_db +from fastcheck_api.app.schemas.checks import ( + CreateCheckRequest, + EvaluationJobOut, + ListJobsResponse, + ListResultsResponse, +) +from fastcheck_api.app.services.audit_service import AuditService +from fastcheck_api.app.services.check_service import CheckService, InsufficientCreditsError + + +router = APIRouter(prefix="/checks", tags=["checks"]) +API_PREFIX = settings.normalized_api_prefix or "/api/v1" + + +@router.get( + "", + response_model=ListJobsResponse, + summary="List checks", + description="List evaluation jobs (checks) for the current tenant.", +) +async def list_checks( + user: Annotated[CurrentUser, Depends(require_permission("evaluation:read"))], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], + page: int = Query(default=1, ge=1), + limit: int = Query(default=10, ge=1, le=100), +): + return await CheckService.list_jobs(db=db, tenant_id=user.tenant, page=page, limit=limit) + + +@router.post( + "", + response_model=EvaluationJobOut, + status_code=status.HTTP_201_CREATED, + summary="Create a check", + description="Create a single evaluation job and enqueue processing in RabbitMQ using the existing queue and message format.", + openapi_extra={ + "x-code-samples": [ + { + "lang": "curl", + "label": "cURL", + "source": f"curl -X POST 'http://127.0.0.1:8181{API_PREFIX}/checks' \\\n -H 'Authorization: Bearer ' \\\n -H 'Content-Type: application/json' \\\n -d '{{\"supplier\":{{\"rut\":\"12345678-9\",\"name\":\"ACME SpA\"}}}}'", + }, + { + "lang": "python", + "label": "httpx", + "source": f"import httpx\nresp = httpx.post('http://127.0.0.1:8181{API_PREFIX}/checks',\n headers={{'Authorization': 'Bearer '}},\n json={{'supplier': {{'rut': '12345678-9', 'name': 'ACME SpA'}}}})\nprint(resp.json())", + }, + { + "lang": "js", + "label": "fetch", + "source": f"const resp = await fetch('http://127.0.0.1:8181{API_PREFIX}/checks', {{\n method: 'POST',\n headers: {{\n 'Authorization': 'Bearer ',\n 'Content-Type': 'application/json'\n }},\n body: JSON.stringify({{ supplier: {{ rut: '12345678-9', name: 'ACME SpA' }} }})\n}});\nconsole.log(await resp.json());", + }, + ] + }, +) +async def create_check( + request: Request, + payload: CreateCheckRequest, + user: Annotated[CurrentUser, Depends(require_permission("evaluation:create"))], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], +): + start = time.perf_counter() + try: + job = await CheckService.create_single_check( + db=db, + tenant_id=user.tenant, + user_id=user.id, + rut=payload.supplier.rut, + name=payload.supplier.name, + ) + await AuditService.log_consulta( + db=db, + tenant_id=user.tenant, + user_id=user.id, + consulta_type="individual", + rut=payload.supplier.rut, + endpoint="/api/v1/checks", + response_status=status.HTTP_201_CREATED, + request_data={"supplier": {"rut": payload.supplier.rut, "name": payload.supplier.name}}, + processing_time_ms=int((time.perf_counter() - start) * 1000), + credits_used=1, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + metadata={"evaluationType": "single"}, + ) + return job + except InsufficientCreditsError: + await AuditService.log_consulta( + db=db, + tenant_id=user.tenant, + user_id=user.id, + consulta_type="individual", + rut=payload.supplier.rut, + endpoint="/api/v1/checks", + response_status=status.HTTP_402_PAYMENT_REQUIRED, + request_data={"supplier": {"rut": payload.supplier.rut, "name": payload.supplier.name}}, + processing_time_ms=int((time.perf_counter() - start) * 1000), + credits_used=0, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + metadata={"reason": "insufficient_credits"}, + ) + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail="Insufficient credits", + ) + + +@router.get( + "/{job_id}", + response_model=EvaluationJobOut, + summary="Get a check", + description="Fetch a single evaluation job by id (tenant-scoped).", +) +async def get_check( + job_id: str, + user: Annotated[CurrentUser, Depends(require_permission("evaluation:read"))], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], +): + job = await CheckService.get_job(db=db, tenant_id=user.tenant, job_id=job_id) + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + return job + + +@router.get( + "/{job_id}/results", + response_model=ListResultsResponse, + summary="List check results", + description="List evaluation results for a given job id (tenant-scoped).", + openapi_extra={ + "x-code-samples": [ + { + "lang": "curl", + "label": "cURL", + "source": f"curl 'http://127.0.0.1:8181{API_PREFIX}/checks//results?page=1&limit=10' \\\n -H 'Authorization: Bearer '", + }, + { + "lang": "python", + "label": "httpx", + "source": f"import httpx\nresp = httpx.get('http://127.0.0.1:8181{API_PREFIX}/checks//results',\n params={{'page': 1, 'limit': 10}},\n headers={{'Authorization': 'Bearer '}})\nprint(resp.json())", + }, + ] + }, +) +async def get_check_results( + job_id: str, + user: Annotated[CurrentUser, Depends(require_permission("evaluation:read"))], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], + page: int = Query(default=1, ge=1), + limit: int = Query(default=10, ge=1, le=100), +): + try: + return await CheckService.list_results(db=db, tenant_id=user.tenant, job_id=job_id, page=page, limit=limit) + except LookupError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/fastcheck_api/app/api/v1/routers/reports.py b/fastcheck_api/app/api/v1/routers/reports.py new file mode 100644 index 0000000..6b07a55 --- /dev/null +++ b/fastcheck_api/app/api/v1/routers/reports.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from motor.motor_asyncio import AsyncIOMotorDatabase + +from fastcheck_api.app.api.dependencies import CurrentUser, require_permission +from fastcheck_api.app.core.mongodb import get_db +from fastcheck_api.app.schemas.reports import FastCheckReportOut +from fastcheck_api.app.utils.mongo import jsonable + + +router = APIRouter(prefix="/reports", tags=["reports"]) + + +@router.get( + "/fast-check/{rut}", + response_model=FastCheckReportOut, + summary="Get FastCheck report", + description="Fetch the latest saved FastCheck summary for a RUT from the existing `summaries` collection (tenant-scoped).", +) +async def get_fastcheck_report( + rut: str, + user: Annotated[CurrentUser, Depends(require_permission("evaluation:read"))], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], +): + doc = ( + await db["summaries"] + .find({"rut": rut, "tenantId": user.tenant, "summaryType": "fast-check"}) + .sort([("createdAt", -1)]) + .limit(1) + .to_list(length=1) + ) + if not doc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found") + return jsonable(doc[0]) diff --git a/fastcheck_api/app/api/v1/routers/usage.py b/fastcheck_api/app/api/v1/routers/usage.py new file mode 100644 index 0000000..f32f180 --- /dev/null +++ b/fastcheck_api/app/api/v1/routers/usage.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Annotated + +from bson import ObjectId +from fastapi import APIRouter, Depends, HTTPException, Query, status +from motor.motor_asyncio import AsyncIOMotorDatabase + +from fastcheck_api.app.api.dependencies import CurrentUser, require_permission +from fastcheck_api.app.core.mongodb import get_db +from fastcheck_api.app.schemas.usage import ListCreditOperationsResponse, TenantUsageOut +from fastcheck_api.app.utils.mongo import jsonable + + +router = APIRouter(prefix="/usage", tags=["usage"]) + + +@router.get( + "", + response_model=TenantUsageOut, + summary="Get tenant usage", + description="Return tenant credit balance from the existing `tenants.creditBalance` structure.", +) +async def get_usage( + user: Annotated[CurrentUser, Depends(require_permission("tenant:read"))], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], +): + tenant = await db["tenants"].find_one({"_id": ObjectId(user.tenant)}, projection={"creditBalance": 1}) + if not tenant: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + credit_balance = tenant.get("creditBalance") or {} + return {"tenantId": user.tenant, "creditBalance": jsonable(credit_balance)} + + +@router.get( + "/operations", + response_model=ListCreditOperationsResponse, + summary="List credit operations", + description="List credit operations for the current tenant from the existing `creditoperations` ledger.", +) +async def list_operations( + user: Annotated[CurrentUser, Depends(require_permission("tenant:read"))], + db: Annotated[AsyncIOMotorDatabase, Depends(get_db)], + page: int = Query(default=1, ge=1), + limit: int = Query(default=20, ge=1, le=100), +): + skip = (page - 1) * limit + tenant_oid = ObjectId(user.tenant) + cursor = ( + db["creditoperations"] + .find({"tenantId": tenant_oid}) + .sort([("createdAt", -1)]) + .skip(skip) + .limit(limit) + ) + operations = [jsonable(doc) async for doc in cursor] + total = await db["creditoperations"].count_documents({"tenantId": tenant_oid}) + pages = (total + limit - 1) // limit + return {"operations": operations, "total": total, "pages": pages} diff --git a/fastcheck_api/app/core/__init__.py b/fastcheck_api/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/core/config.py b/fastcheck_api/app/core/config.py new file mode 100644 index 0000000..5dda561 --- /dev/null +++ b/fastcheck_api/app/core/config.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +ENV_FILE = Path(__file__).resolve().parents[2] / ".env" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=ENV_FILE, env_file_encoding="utf-8", extra="ignore") + + mongodb_uri: str = Field(default="mongodb://localhost:27017/duxiter", validation_alias="MONGODB_URI") + + jwt_secret: str = Field(default="your-secret-key", validation_alias="VITE_JWT_SECRET") + jwt_expires_in: str = Field(default="7d", validation_alias="VITE_JWT_EXPIRES_IN") + + api_host: str = Field(default="127.0.0.1", validation_alias="FASTCHECK_API_HOST") + api_port: int = Field(default=8080, validation_alias="FASTCHECK_API_PORT") + api_prefix: str = Field(default="/api/v1", validation_alias="FASTCHECK_API_PREFIX") + log_level: str = Field(default="INFO", validation_alias="FASTCHECK_LOG_LEVEL") + + legacy_base_url: str = Field(default="http://localhost:4040/api", validation_alias="LEGACY_BASE_URL") + legacy_http_timeout_seconds: int = Field(default=30, validation_alias="LEGACY_HTTP_TIMEOUT_SECONDS") + + rabbitmq_enabled: bool = Field(default=True, validation_alias="RABBITMQ_ENABLED") + rabbitmq_queue: str = Field(default="evaluation_queue", validation_alias="RABBITMQ_QUEUE") + rabbitmq_url: str | None = Field(default=None, validation_alias="RABBITMQ_URL") + rabbitmq_protocol: str = Field(default="amqp", validation_alias="RABBITMQ_PROTOCOL") + rabbitmq_username: str = Field(default="guest", validation_alias="RABBITMQ_USERNAME") + rabbitmq_password: str = Field(default="guest", validation_alias="RABBITMQ_PASSWORD") + rabbitmq_host: str = Field(default="localhost", validation_alias="RABBITMQ_HOST") + rabbitmq_port: int = Field(default=5672, validation_alias="RABBITMQ_PORT") + rabbitmq_vhost: str = Field(default="/", validation_alias="RABBITMQ_VHOST") + + @property + def mongo_db_name(self) -> str: + uri = self.mongodb_uri + after_slash = uri.rsplit("/", 1)[-1] + if "?" in after_slash: + return after_slash.split("?", 1)[0] + if after_slash: + return after_slash + return "duxiter" + + @property + def rabbitmq_connection_url(self) -> str: + if self.rabbitmq_url: + return self.rabbitmq_url + vhost = self.rabbitmq_vhost + if vhost.startswith("/"): + vhost = vhost[1:] + return ( + f"{self.rabbitmq_protocol}://{self.rabbitmq_username}:{self.rabbitmq_password}" + f"@{self.rabbitmq_host}:{self.rabbitmq_port}/{vhost}" + ) + + @property + def normalized_api_prefix(self) -> str: + prefix = (self.api_prefix or "").strip() + if not prefix: + return "" + if not prefix.startswith("/"): + prefix = f"/{prefix}" + if len(prefix) > 1 and prefix.endswith("/"): + prefix = prefix[:-1] + return prefix + + +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/fastcheck_api/app/core/logging.py b/fastcheck_api/app/core/logging.py new file mode 100644 index 0000000..aa7a6aa --- /dev/null +++ b/fastcheck_api/app/core/logging.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import logging +import sys + +from fastcheck_api.app.core.config import settings + + +def configure_logging() -> None: + root = logging.getLogger() + if root.handlers: + return + + level = getattr(logging, settings.log_level.upper(), logging.INFO) + root.setLevel(level) + + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(level) + handler.setFormatter( + logging.Formatter( + fmt="%(asctime)s %(levelname)s %(name)s %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z", + ) + ) + root.addHandler(handler) diff --git a/fastcheck_api/app/core/mongodb.py b/fastcheck_api/app/core/mongodb.py new file mode 100644 index 0000000..b3e6708 --- /dev/null +++ b/fastcheck_api/app/core/mongodb.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase + +from fastcheck_api.app.core.config import settings + +_client: AsyncIOMotorClient | None = None + + +def get_client() -> AsyncIOMotorClient: + global _client + if _client is None: + _client = AsyncIOMotorClient(settings.mongodb_uri) + return _client + + +def get_db() -> AsyncIOMotorDatabase: + return get_client()[settings.mongo_db_name] + + +async def lifespan_db() -> AsyncIterator[None]: + client = get_client() + try: + yield + finally: + client.close() diff --git a/fastcheck_api/app/core/security.py b/fastcheck_api/app/core/security.py new file mode 100644 index 0000000..b358453 --- /dev/null +++ b/fastcheck_api/app/core/security.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import datetime as dt +import re +from typing import Any + +import bcrypt +import jwt + +from fastcheck_api.app.core.config import settings + + +class AuthError(Exception): + pass + + +_DURATION_RE = re.compile(r"^(?P\d+)(?P[smhd])$") + + +def _parse_expires_in(value: str) -> dt.timedelta: + match = _DURATION_RE.match(value.strip()) + if not match: + return dt.timedelta(days=7) + count = int(match.group("count")) + unit = match.group("unit") + if unit == "s": + return dt.timedelta(seconds=count) + if unit == "m": + return dt.timedelta(minutes=count) + if unit == "h": + return dt.timedelta(hours=count) + return dt.timedelta(days=count) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + try: + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + except Exception: + return False + + +def create_access_token(*, user_id: str, email: str, role: str, tenant_id: str) -> str: + now = dt.datetime.now(dt.timezone.utc) + exp = now + _parse_expires_in(settings.jwt_expires_in) + payload = { + "id": user_id, + "email": email, + "role": role, + "tenant": tenant_id, + "iat": int(now.timestamp()), + "exp": int(exp.timestamp()), + } + return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + +def decode_token(token: str) -> dict[str, Any]: + try: + decoded = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"]) + if not isinstance(decoded, dict): + raise AuthError("Invalid token payload") + return decoded + except jwt.ExpiredSignatureError as e: + raise AuthError("Token expired") from e + except jwt.InvalidTokenError as e: + raise AuthError("Invalid token") from e diff --git a/fastcheck_api/app/main.py b/fastcheck_api/app/main.py new file mode 100644 index 0000000..f05b8cc --- /dev/null +++ b/fastcheck_api/app/main.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi import APIRouter +from fastapi.responses import HTMLResponse, JSONResponse + +from fastcheck_api.app.api.v1.routers import auth, checks, reports, usage +from fastcheck_api.app.core.logging import configure_logging +from fastcheck_api.app.core.mongodb import lifespan_db +from fastcheck_api.app.services.legacy_service import legacy_service +from fastcheck_api.app.services.rabbitmq_service import rabbitmq_service +from fastcheck_api.app.middleware.request_logging import RequestLoggingMiddleware +from fastcheck_api.app.middleware.tenant_context import TenantContextMiddleware +from fastcheck_api.app.core.config import settings + + +logger = logging.getLogger("fastcheck") + +tags_metadata = [ + {"name": "auth", "description": "Authentication and session management."}, + {"name": "checks", "description": "Create checks (evaluations) and retrieve job/results."}, + {"name": "reports", "description": "Fetch saved reports from existing persistence."}, + {"name": "usage", "description": "Usage and credit balance information."}, +] + + +@asynccontextmanager +async def lifespan(app: FastAPI): + configure_logging() + async for _ in lifespan_db(): + if settings.rabbitmq_enabled: + try: + await rabbitmq_service.connect() + except Exception: + logger.exception("RabbitMQ connection failed; continuing without async processing") + try: + yield + finally: + try: + await rabbitmq_service.close() + except Exception: + logger.exception("RabbitMQ shutdown failed") + await legacy_service.close() + + +app = FastAPI( + title="FastCheck API", + version="0.1.0", + description="FastCheck API for check evaluation and report retrieval.", + openapi_url="/openapi.json", + docs_url="/internal/docs", + redoc_url="/internal/redoc", + openapi_tags=tags_metadata, + lifespan=lifespan, +) +app.add_middleware(RequestLoggingMiddleware) +app.add_middleware(TenantContextMiddleware) + + +@app.get( + "/", + summary="API entrypoint", + description="Convenience endpoint with links to the OpenAPI schema and internal Swagger/ReDoc.", +) +async def root(): + return { + "openapi": "/openapi.json", + "docs": "/docs", + "swagger_ui": "/internal/docs", + "redoc": "/internal/redoc", + "health": "/health", + } + + +@app.get( + "/docs", + summary="Customer API docs", + description="Customer-facing API reference with generated code examples. The OpenAPI schema remains the source of truth.", + include_in_schema=False, +) +async def customer_docs(): + return HTMLResponse( + """ + + + + + + FastCheck API Docs + + + + + + + + """.strip() + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.exception("Unhandled error", extra={"path": request.url.path}) + return JSONResponse(status_code=500, content={"status": "error", "message": "Something went wrong!"}) + + +api_v1 = APIRouter(prefix=settings.normalized_api_prefix) +api_v1.include_router(auth.router) +api_v1.include_router(checks.router) +api_v1.include_router(reports.router) +api_v1.include_router(usage.router) +app.include_router(api_v1) diff --git a/fastcheck_api/app/middleware/__init__.py b/fastcheck_api/app/middleware/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/middleware/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/middleware/request_logging.py b/fastcheck_api/app/middleware/request_logging.py new file mode 100644 index 0000000..0b18b0f --- /dev/null +++ b/fastcheck_api/app/middleware/request_logging.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging +import time +import uuid + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + + +logger = logging.getLogger("fastcheck.request") + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + request_id = request.headers.get("x-request-id") or str(uuid.uuid4()) + request.state.request_id = request_id + start = time.perf_counter() + status_code = None + try: + response: Response = await call_next(request) + status_code = response.status_code + finally: + duration_ms = int((time.perf_counter() - start) * 1000) + logger.info( + "request", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status_code": status_code, + "duration_ms": duration_ms, + }, + ) + response.headers["x-request-id"] = request_id + return response diff --git a/fastcheck_api/app/middleware/tenant_context.py b/fastcheck_api/app/middleware/tenant_context.py new file mode 100644 index 0000000..2c2adc8 --- /dev/null +++ b/fastcheck_api/app/middleware/tenant_context.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware + + +class TenantContextMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + if not hasattr(request.state, "tenant_id"): + request.state.tenant_id = None + return await call_next(request) diff --git a/fastcheck_api/app/schemas/__init__.py b/fastcheck_api/app/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/schemas/auth.py b/fastcheck_api/app/schemas/auth.py new file mode 100644 index 0000000..bdd48c0 --- /dev/null +++ b/fastcheck_api/app/schemas/auth.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pydantic import BaseModel, EmailStr, Field + + +class LoginRequest(BaseModel): + email: EmailStr = Field(description="User email address.", examples=["user@example.com"]) + password: str = Field(description="User password.", examples=["********"]) + + +class UserOut(BaseModel): + id: str = Field(description="User id (MongoDB ObjectId as string).", examples=["507f1f77bcf86cd799439011"]) + name: str = Field(description="User display name.", examples=["Jane Doe"]) + email: EmailStr = Field(description="User email address.", examples=["user@example.com"]) + role: str = Field( + description="User role (source of truth for access control).", + examples=["tenant_admin"], + ) + + +class LoginResponse(BaseModel): + token: str = Field(description="Bearer JWT token.", examples=[""]) + user: UserOut diff --git a/fastcheck_api/app/schemas/checks.py b/fastcheck_api/app/schemas/checks.py new file mode 100644 index 0000000..3dc7530 --- /dev/null +++ b/fastcheck_api/app/schemas/checks.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class SupplierInput(BaseModel): + rut: str = Field( + description="Chilean RUT in canonical format (recommended: no dots, with dash).", + examples=["12345678-9"], + ) + name: str | None = Field(default=None, description="Optional supplier/company name.", examples=["ACME SpA"]) + + +class CreateCheckRequest(BaseModel): + supplier: SupplierInput = Field(description="Supplier to evaluate.") + + +class EvaluationJobOut(BaseModel): + id: str = Field(alias="_id", description="Evaluation job id (MongoDB ObjectId as string).", examples=["507f1f77bcf86cd799439011"]) + tenantId: str = Field(description="Tenant id (string stored in jobs/results collections).") + status: str = Field(description="Job status.", examples=["processing"]) + type: str = Field(description="Job type.", examples=["single"]) + createdBy: str = Field(description="User id that created the job (string).") + totalEvaluations: int = Field(description="Total evaluations tracked by this job.", examples=[1]) + completedEvaluations: int = Field(description="Number of successful results.", examples=[0]) + failedEvaluations: int = Field(description="Number of failed results.", examples=[0]) + createdAt: datetime | None = None + updatedAt: datetime | None = None + supplierData: dict[str, Any] | None = None + + +class ListJobsResponse(BaseModel): + jobs: list[EvaluationJobOut] + total: int = Field(description="Total jobs in this tenant.", examples=[42]) + pages: int = Field(description="Total pages for the current limit.", examples=[5]) + + +class EvaluationResultOut(BaseModel): + id: str = Field(alias="_id", description="Evaluation result id (MongoDB ObjectId as string).") + jobId: str = Field(description="Evaluation job id (as string or ObjectId string).") + tenantId: str = Field(description="Tenant id (string stored in jobs/results collections).") + rut: str = Field(description="Supplier RUT.", examples=["12345678-9"]) + name: str | None = Field(default=None, description="Optional supplier name.", examples=["ACME SpA"]) + status: str = Field(description="Result status.", examples=["success"]) + error: str | None = Field(default=None, description="Error message if failed.") + data: dict[str, Any] | None = None + createdAt: datetime | None = None + updatedAt: datetime | None = None + + +class ListResultsResponse(BaseModel): + results: list[EvaluationResultOut] + total: int = Field(description="Total results in this job.", examples=[1]) + pages: int = Field(description="Total pages for the current limit.", examples=[1]) diff --git a/fastcheck_api/app/schemas/common.py b/fastcheck_api/app/schemas/common.py new file mode 100644 index 0000000..b6771e2 --- /dev/null +++ b/fastcheck_api/app/schemas/common.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class Page(BaseModel): + page: int = Field(default=1, ge=1) + limit: int = Field(default=10, ge=1, le=100) diff --git a/fastcheck_api/app/schemas/reports.py b/fastcheck_api/app/schemas/reports.py new file mode 100644 index 0000000..62c9ba8 --- /dev/null +++ b/fastcheck_api/app/schemas/reports.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class FastCheckReportOut(BaseModel): + id: str = Field(alias="_id", description="Summary id (MongoDB ObjectId as string).") + rut: str = Field(description="Supplier RUT.", examples=["12345678-9"]) + tenantId: str = Field(description="Tenant id (string stored in summaries).") + summaryType: str = Field(description="Summary type.", examples=["fast-check"]) + content: str = Field(description="Rendered fast-check summary content (Markdown or plain text).") + metadata: dict[str, Any] | None = None + createdAt: datetime | None = None + updatedAt: datetime | None = None diff --git a/fastcheck_api/app/schemas/usage.py b/fastcheck_api/app/schemas/usage.py new file mode 100644 index 0000000..48c3ab5 --- /dev/null +++ b/fastcheck_api/app/schemas/usage.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class CreditBalanceOut(BaseModel): + availableCredits: int = Field(description="Remaining credits for the tenant.", examples=[99]) + totalCreditsUsed: int = Field(description="Total credits used by the tenant.", examples=[1]) + lastCreditOperation: datetime | None = None + + +class TenantUsageOut(BaseModel): + tenantId: str = Field(description="Tenant id (MongoDB ObjectId as string).", examples=["507f1f77bcf86cd799439012"]) + creditBalance: CreditBalanceOut + + +class CreditOperationOut(BaseModel): + id: str = Field(alias="_id", description="Credit operation id (MongoDB ObjectId as string).") + tenantId: str = Field(description="Tenant id (MongoDB ObjectId as string).") + userId: str | None = Field(default=None, description="User id (MongoDB ObjectId as string).") + operationType: str = Field(description="Operation type.", examples=["evaluation"]) + creditsChanged: int = Field(description="Positive for additions, negative for deductions.", examples=[-1]) + balanceAfter: int = Field(description="Tenant balance after this operation.", examples=[99]) + description: str | None = Field(default=None, description="Human-readable description.") + metadata: dict[str, Any] | None = None + createdAt: datetime | None = None + + +class ListCreditOperationsResponse(BaseModel): + operations: list[CreditOperationOut] + total: int = Field(description="Total operations for this tenant.", examples=[123]) + pages: int = Field(description="Total pages for the current limit.", examples=[7]) diff --git a/fastcheck_api/app/services/__init__.py b/fastcheck_api/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/services/audit_service.py b/fastcheck_api/app/services/audit_service.py new file mode 100644 index 0000000..e2c8b83 --- /dev/null +++ b/fastcheck_api/app/services/audit_service.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import datetime as dt +from typing import Any + +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorDatabase + + +class AuditService: + @staticmethod + async def log_consulta( + *, + db: AsyncIOMotorDatabase, + tenant_id: str, + user_id: str | None, + consulta_type: str, + endpoint: str, + response_status: int, + rut: str | None = None, + ruts: list[str] | None = None, + request_data: dict[str, Any] | None = None, + response_data: dict[str, Any] | None = None, + error_message: str | None = None, + processing_time_ms: int | None = None, + credits_used: int | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + doc: dict[str, Any] = { + "tenantId": tenant_id, + "consultaType": consulta_type, + "endpoint": endpoint, + "responseStatus": response_status, + "createdAt": dt.datetime.now(dt.timezone.utc), + "updatedAt": dt.datetime.now(dt.timezone.utc), + } + if user_id: + try: + doc["userId"] = ObjectId(user_id) + except Exception: + pass + if rut: + doc["rut"] = rut + if ruts: + doc["ruts"] = ruts + if request_data is not None: + doc["requestData"] = request_data + if response_data is not None: + doc["responseData"] = response_data + if error_message: + doc["errorMessage"] = error_message + if processing_time_ms is not None: + doc["processingTime"] = processing_time_ms + if credits_used is not None: + doc["creditsUsed"] = credits_used + if ip_address: + doc["ipAddress"] = ip_address + if user_agent: + doc["userAgent"] = user_agent + if metadata is not None: + doc["metadata"] = metadata + await db["consulta_history"].insert_one(doc) diff --git a/fastcheck_api/app/services/auth_service.py b/fastcheck_api/app/services/auth_service.py new file mode 100644 index 0000000..594ee44 --- /dev/null +++ b/fastcheck_api/app/services/auth_service.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorDatabase + +from fastcheck_api.app.core.security import create_access_token, verify_password + + +class AuthService: + @staticmethod + async def login(*, db: AsyncIOMotorDatabase, email: str, password: str) -> dict: + user = await db["users"].find_one({"email": email.lower()}) + if not user: + raise ValueError("Invalid credentials") + + if not user.get("isActive", False): + raise PermissionError("ACCOUNT_NOT_ACTIVATED") + + stored = user.get("password") + if not stored: + raise ValueError("Invalid credentials") + + if not verify_password(password, stored): + raise ValueError("Invalid credentials") + + tenant_id = user.get("tenant") + if isinstance(tenant_id, ObjectId): + tenant_id_str = str(tenant_id) + else: + tenant_id_str = str(tenant_id or "") + + token = create_access_token( + user_id=str(user["_id"]), + email=str(user.get("email") or ""), + role=str(user.get("role") or ""), + tenant_id=tenant_id_str, + ) + + return { + "token": token, + "user": { + "id": str(user["_id"]), + "name": str(user.get("name") or ""), + "email": str(user.get("email") or ""), + "role": str(user.get("role") or ""), + }, + } diff --git a/fastcheck_api/app/services/check_service.py b/fastcheck_api/app/services/check_service.py new file mode 100644 index 0000000..4f8d682 --- /dev/null +++ b/fastcheck_api/app/services/check_service.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import datetime as dt +from typing import Any + +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorDatabase +from pymongo import ReturnDocument + +from fastcheck_api.app.services.rabbitmq_service import rabbitmq_service +from fastcheck_api.app.utils.mongo import jsonable, to_object_id + + +class InsufficientCreditsError(Exception): + pass + + +class CheckService: + @staticmethod + async def create_single_check( + *, + db: AsyncIOMotorDatabase, + tenant_id: str, + user_id: str, + rut: str, + name: str | None, + ) -> dict[str, Any]: + now = dt.datetime.now(dt.timezone.utc) + tenant_oid = to_object_id(tenant_id) + + tenant_after = await db["tenants"].find_one_and_update( + {"_id": tenant_oid, "creditBalance.availableCredits": {"$gte": 1}}, + { + "$inc": { + "creditBalance.availableCredits": -1, + "creditBalance.totalCreditsUsed": 1, + }, + "$set": { + "creditBalance.lastCreditOperation": now, + "updatedAt": now, + }, + }, + return_document=ReturnDocument.AFTER, + ) + if not tenant_after: + raise InsufficientCreditsError("Insufficient credits") + + new_balance = int((tenant_after.get("creditBalance") or {}).get("availableCredits") or 0) + + await db["creditoperations"].insert_one( + { + "tenantId": tenant_oid, + "userId": to_object_id(user_id), + "operationType": "evaluation", + "creditsChanged": -1, + "balanceAfter": new_balance, + "description": f"Valutazione singola per {rut}", + "metadata": {"evaluationType": "single", "supplierRut": rut, "supplierName": name}, + "createdAt": now, + } + ) + + job_doc: dict[str, Any] = { + "tenantId": tenant_id, + "status": "processing", + "type": "single", + "createdBy": user_id, + "totalEvaluations": 1, + "completedEvaluations": 0, + "failedEvaluations": 0, + "supplierData": {"rut": rut, "name": name or "N/A"}, + "createdAt": now, + "updatedAt": now, + } + insert = await db["evaluationjobs"].insert_one(job_doc) + job_id = str(insert.inserted_id) + + await rabbitmq_service.publish_evaluation( + {"rut": rut, "name": name, "jobId": job_id, "tenantId": tenant_id, "userId": user_id} + ) + + stored = await db["evaluationjobs"].find_one({"_id": insert.inserted_id}) + if not stored: + raise RuntimeError("Failed to create job") + return jsonable(stored) + + @staticmethod + async def list_jobs(*, db: AsyncIOMotorDatabase, tenant_id: str, page: int, limit: int) -> dict[str, Any]: + skip = (page - 1) * limit + cursor = ( + db["evaluationjobs"] + .find({"tenantId": tenant_id}) + .sort([("createdAt", -1)]) + .skip(skip) + .limit(limit) + ) + jobs = [jsonable(doc) async for doc in cursor] + total = await db["evaluationjobs"].count_documents({"tenantId": tenant_id}) + pages = (total + limit - 1) // limit + return {"jobs": jobs, "total": total, "pages": pages} + + @staticmethod + async def get_job(*, db: AsyncIOMotorDatabase, tenant_id: str, job_id: str) -> dict[str, Any] | None: + try: + oid = ObjectId(job_id) + except Exception: + return None + doc = await db["evaluationjobs"].find_one({"_id": oid, "tenantId": tenant_id}) + return jsonable(doc) if doc else None + + @staticmethod + async def list_results( + *, db: AsyncIOMotorDatabase, tenant_id: str, job_id: str, page: int, limit: int + ) -> dict[str, Any]: + try: + oid = ObjectId(job_id) + except Exception: + raise ValueError("Invalid job id") + + job = await db["evaluationjobs"].find_one({"_id": oid, "tenantId": tenant_id}) + if not job: + raise LookupError("Job not found") + + skip = (page - 1) * limit + cursor = ( + db["evaluationresults"] + .find({"jobId": oid, "tenantId": tenant_id}) + .sort([("createdAt", -1)]) + .skip(skip) + .limit(limit) + ) + results = [jsonable(doc) async for doc in cursor] + total = await db["evaluationresults"].count_documents({"jobId": oid, "tenantId": tenant_id}) + pages = (total + limit - 1) // limit + return {"results": results, "total": total, "pages": pages} diff --git a/fastcheck_api/app/services/legacy_service.py b/fastcheck_api/app/services/legacy_service.py new file mode 100644 index 0000000..3597c61 --- /dev/null +++ b/fastcheck_api/app/services/legacy_service.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from fastcheck_api.app.core.config import settings + + +class LegacyService: + def __init__(self) -> None: + self._client = httpx.AsyncClient( + base_url=settings.legacy_base_url, + timeout=settings.legacy_http_timeout_seconds, + ) + + async def close(self) -> None: + await self._client.aclose() + + async def lookup_rut(self, *, rut: str, token: str) -> dict[str, Any]: + resp = await self._client.post( + "rut/lookup", + headers={"Authorization": f"Bearer {token}"}, + json={"rut": rut, "isMonitoring": False, "type": "masiva"}, + ) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("Unexpected legacy response") + return data + + async def save_fastcheck_summary(self, *, rut: str, summary: str, token: str) -> dict[str, Any]: + resp = await self._client.post( + f"rut/fast-check-summary/{rut}", + headers={"Authorization": f"Bearer {token}"}, + json={"summary": summary}, + ) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("Unexpected legacy response") + return data + + +legacy_service = LegacyService() diff --git a/fastcheck_api/app/services/rabbitmq_service.py b/fastcheck_api/app/services/rabbitmq_service.py new file mode 100644 index 0000000..d1c641c --- /dev/null +++ b/fastcheck_api/app/services/rabbitmq_service.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +from typing import Any + +import aio_pika + +from fastcheck_api.app.core.config import settings + + +class RabbitMQService: + def __init__(self) -> None: + self._connection: aio_pika.RobustConnection | None = None + self._channel: aio_pika.RobustChannel | None = None + + async def connect(self) -> None: + if not settings.rabbitmq_enabled: + return + if self._connection and not self._connection.is_closed: + return + self._connection = await aio_pika.connect_robust(settings.rabbitmq_connection_url) + self._channel = await self._connection.channel() + await self._channel.set_qos(prefetch_count=1) + await self._channel.declare_queue(settings.rabbitmq_queue, durable=True) + + async def publish_evaluation(self, data: dict[str, Any]) -> None: + if not settings.rabbitmq_enabled: + return + await self.connect() + if not self._channel: + raise RuntimeError("RabbitMQ channel not available") + exchange = self._channel.default_exchange + message = aio_pika.Message( + body=json.dumps(data).encode("utf-8"), + delivery_mode=aio_pika.DeliveryMode.PERSISTENT, + content_type="application/json", + ) + await exchange.publish(message, routing_key=settings.rabbitmq_queue) + + async def close(self) -> None: + if self._channel and not self._channel.is_closed: + await self._channel.close() + if self._connection and not self._connection.is_closed: + await self._connection.close() + + +rabbitmq_service = RabbitMQService() diff --git a/fastcheck_api/app/utils/__init__.py b/fastcheck_api/app/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/utils/mongo.py b/fastcheck_api/app/utils/mongo.py new file mode 100644 index 0000000..fb48804 --- /dev/null +++ b/fastcheck_api/app/utils/mongo.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any + +from bson import ObjectId + + +def to_object_id(value: str) -> ObjectId: + return ObjectId(value) + + +def jsonable(value: Any) -> Any: + if isinstance(value, ObjectId): + return str(value) + if isinstance(value, datetime): + return value + if isinstance(value, Mapping): + return {k: jsonable(v) for k, v in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [jsonable(v) for v in value] + return value diff --git a/fastcheck_api/app/workers/__init__.py b/fastcheck_api/app/workers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastcheck_api/app/workers/__init__.py @@ -0,0 +1 @@ + diff --git a/fastcheck_api/app/workers/check_worker.py b/fastcheck_api/app/workers/check_worker.py new file mode 100644 index 0000000..933b568 --- /dev/null +++ b/fastcheck_api/app/workers/check_worker.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import asyncio +import datetime as dt +import json +import logging +from typing import Any + +import aio_pika +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorClient + +from fastcheck_api.app.core.config import settings +from fastcheck_api.app.core.logging import configure_logging +from fastcheck_api.app.core.security import create_access_token +from fastcheck_api.app.services.legacy_service import legacy_service + + +logger = logging.getLogger("fastcheck.worker") + + +def _db() -> Any: + client = AsyncIOMotorClient(settings.mongodb_uri) + return client, client[settings.mongo_db_name] + + +async def _update_job_progress(db: Any, job_oid: ObjectId, tenant_id: str) -> None: + job = await db["evaluationjobs"].find_one({"_id": job_oid, "tenantId": tenant_id}) + if not job: + return + total = int(job.get("totalEvaluations") or 0) + completed = await db["evaluationresults"].count_documents({"jobId": job_oid, "tenantId": tenant_id, "status": "success"}) + failed = await db["evaluationresults"].count_documents({"jobId": job_oid, "tenantId": tenant_id, "status": "failed"}) + status = "processing" + if total and (completed + failed) >= total: + status = "completed" + await db["evaluationjobs"].update_one( + {"_id": job_oid, "tenantId": tenant_id}, + { + "$set": { + "completedEvaluations": completed, + "failedEvaluations": failed, + "status": status, + "updatedAt": dt.datetime.now(dt.timezone.utc), + } + }, + ) + + +async def _process_payload(db: Any, payload: dict[str, Any]) -> None: + rut = str(payload.get("rut") or "") + name = payload.get("name") + tenant_id = str(payload.get("tenantId") or "") + user_id = str(payload.get("userId") or "") + job_id = str(payload.get("jobId") or "") + + if not rut or not tenant_id or not user_id or not job_id: + raise ValueError("Invalid message payload") + + job_oid = ObjectId(job_id) + user_oid = ObjectId(user_id) + user = await db["users"].find_one({"_id": user_oid}, projection={"email": 1, "role": 1, "tenant": 1}) + if not user: + raise RuntimeError("User not found") + + token = create_access_token( + user_id=user_id, + email=str(user.get("email") or ""), + role=str(user.get("role") or ""), + tenant_id=tenant_id, + ) + + now = dt.datetime.now(dt.timezone.utc) + + try: + result_data = await legacy_service.lookup_rut(rut=rut, token=token) + ai_summary = result_data.get("aiAnalysis") or None + if ai_summary and isinstance(ai_summary, str) and ai_summary.strip(): + try: + await legacy_service.save_fastcheck_summary(rut=rut, summary=ai_summary, token=token) + except Exception: + logger.exception("Failed to save fast-check summary via legacy endpoint") + + await db["evaluationresults"].insert_one( + { + "jobId": job_oid, + "tenantId": tenant_id, + "rut": rut, + "name": name, + "status": "success", + "data": result_data, + "createdAt": now, + "updatedAt": now, + } + ) + except Exception as e: + await db["evaluationresults"].insert_one( + { + "jobId": job_oid, + "tenantId": tenant_id, + "rut": rut, + "name": name, + "status": "failed", + "error": str(e), + "createdAt": now, + "updatedAt": now, + } + ) + finally: + await _update_job_progress(db, job_oid, tenant_id) + + +async def run_worker() -> None: + configure_logging() + if not settings.rabbitmq_enabled: + raise RuntimeError("RabbitMQ disabled") + + client, db = _db() + connection: aio_pika.RobustConnection | None = None + try: + connection = await aio_pika.connect_robust(settings.rabbitmq_connection_url) + channel = await connection.channel() + await channel.set_qos(prefetch_count=1) + queue = await channel.declare_queue(settings.rabbitmq_queue, durable=True) + + async with queue.iterator() as iterator: + async for message in iterator: + async with message.process(requeue=True): + payload = json.loads(message.body.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("Invalid message body") + await _process_payload(db, payload) + finally: + try: + await legacy_service.close() + except Exception: + pass + if connection and not connection.is_closed: + await connection.close() + client.close() + + +def main() -> None: + asyncio.run(run_worker()) + + +if __name__ == "__main__": + main() diff --git a/fastcheck_api/docs/api_reference.md b/fastcheck_api/docs/api_reference.md new file mode 100644 index 0000000..29930f4 --- /dev/null +++ b/fastcheck_api/docs/api_reference.md @@ -0,0 +1,19 @@ +# API Reference + +The OpenAPI schema is the source of truth: + +- JSON schema: `GET /openapi.json` +- Swagger UI (internal): `/internal/docs` +- ReDoc (internal): `/internal/redoc` + +## Groups + +- `auth`: login, refresh, profile +- `checks`: create and query evaluation jobs/results +- `reports`: fetch saved summaries/reports +- `usage`: tenant usage and credit operations + +## Compatibility notes + +- Identity, tenants, and permissions are derived from existing MongoDB collections and fields. +- The system does not introduce new auth/user/tenant collections. diff --git a/fastcheck_api/docs/authentication.md b/fastcheck_api/docs/authentication.md new file mode 100644 index 0000000..af5b722 --- /dev/null +++ b/fastcheck_api/docs/authentication.md @@ -0,0 +1,62 @@ +# Authentication + +FastCheck uses bearer JWT authentication. + +## Source of truth + +Authentication is compatible with the existing Node.js backend: + +- Users are stored in the existing `users` collection. +- Passwords are verified against the existing bcrypt hash in `users.password`. +- Tenant membership is read from `users.tenant`. +- Access control is enforced using `users.role` (role → permissions mapping in code). + +## Obtain a token + +### cURL + +```bash +curl -sS -X POST "http://127.0.0.1:8080/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"********"}' +``` + +### Python (httpx) + +```python +import httpx + +resp = httpx.post( + "http://127.0.0.1:8080/api/v1/auth/login", + json={"email": "user@example.com", "password": "********"}, +) +resp.raise_for_status() +token = resp.json()["token"] +``` + +### JavaScript (fetch) + +```js +const resp = await fetch("http://127.0.0.1:8080/api/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "user@example.com", password: "********" }), +}); +if (!resp.ok) throw new Error(await resp.text()); +const { token } = await resp.json(); +``` + +## Use the token + +Send it as: + +``` +Authorization: Bearer +``` + +## Token refresh + +```bash +curl -sS -X POST "http://127.0.0.1:8080/api/v1/auth/refresh" \ + -H "Authorization: Bearer " +``` diff --git a/fastcheck_api/docs/examples.md b/fastcheck_api/docs/examples.md new file mode 100644 index 0000000..ed12fb4 --- /dev/null +++ b/fastcheck_api/docs/examples.md @@ -0,0 +1,85 @@ +# End-to-end examples + +## Create a check, poll results, fetch report (cURL) + +```bash +API="http://127.0.0.1:8080" + +TOKEN="$(curl -sS -X POST "$API/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"********"}' | python -c 'import sys, json; print(json.load(sys.stdin)["token"])')" + +JOB_ID="$(curl -sS -X POST "$API/api/v1/checks" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"supplier":{"rut":"12345678-9","name":"ACME SpA"}}' | python -c 'import sys, json; print(json.load(sys.stdin)["_id"])')" + +curl -sS "$API/api/v1/checks/$JOB_ID/results" -H "Authorization: Bearer $TOKEN" + +curl -sS "$API/api/v1/reports/fast-check/12345678-9" -H "Authorization: Bearer $TOKEN" +``` + +## Python example (httpx) + +```python +import time +import httpx + +api = "http://127.0.0.1:8080" + +with httpx.Client() as client: + token = client.post( + f"{api}/api/v1/auth/login", + json={"email": "user@example.com", "password": "********"}, + ).json()["token"] + + headers = {"Authorization": f"Bearer {token}"} + + job = client.post( + f"{api}/api/v1/checks", + headers=headers, + json={"supplier": {"rut": "12345678-9", "name": "ACME SpA"}}, + ).json() + + job_id = job["_id"] + + for _ in range(30): + results = client.get(f"{api}/api/v1/checks/{job_id}/results", headers=headers).json() + if results["total"] > 0: + break + time.sleep(1) + + report = client.get(f"{api}/api/v1/reports/fast-check/12345678-9", headers=headers) + if report.status_code == 200: + print(report.json()["content"]) +``` + +## JavaScript example (fetch) + +```js +const api = "http://127.0.0.1:8080"; + +const loginResp = await fetch(`${api}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "user@example.com", password: "********" }), +}); +if (!loginResp.ok) throw new Error(await loginResp.text()); +const { token } = await loginResp.json(); + +const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + +const createResp = await fetch(`${api}/api/v1/checks`, { + method: "POST", + headers, + body: JSON.stringify({ supplier: { rut: "12345678-9", name: "ACME SpA" } }), +}); +if (!createResp.ok) throw new Error(await createResp.text()); +const job = await createResp.json(); + +const resultsResp = await fetch(`${api}/api/v1/checks/${job._id}/results`, { + headers: { Authorization: `Bearer ${token}` }, +}); +if (!resultsResp.ok) throw new Error(await resultsResp.text()); +console.log(await resultsResp.json()); +``` diff --git a/fastcheck_api/docs/index.md b/fastcheck_api/docs/index.md new file mode 100644 index 0000000..aac2a89 --- /dev/null +++ b/fastcheck_api/docs/index.md @@ -0,0 +1,12 @@ +# FastCheck API Documentation + +- OpenAPI schema (source of truth): `/openapi.json` +- Swagger UI (internal): `/internal/docs` +- ReDoc (internal): `/internal/redoc` + +## Start here + +- [Quickstart](quickstart.md) +- [Authentication](authentication.md) +- [API Reference](api_reference.md) +- [End-to-end examples](examples.md) diff --git a/fastcheck_api/docs/quickstart.md b/fastcheck_api/docs/quickstart.md new file mode 100644 index 0000000..9ca0b8f --- /dev/null +++ b/fastcheck_api/docs/quickstart.md @@ -0,0 +1,45 @@ +# Quickstart + +## 1) Run the API + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r fastcheck_api/requirements.txt + +cp fastcheck_api/.env.example fastcheck_api/.env +export $(cat fastcheck_api/.env | xargs) + +uvicorn fastcheck_api.app.main:app --host ${FASTCHECK_API_HOST:-127.0.0.1} --port ${FASTCHECK_API_PORT:-8080} --reload +``` + +## 2) Login + +```bash +curl -sS -X POST "http://127.0.0.1:8080/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"********"}' +``` + +## 3) Create a check + +```bash +curl -sS -X POST "http://127.0.0.1:8080/api/v1/checks" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"supplier":{"rut":"12345678-9","name":"ACME SpA"}}' +``` + +## 4) Fetch results + +```bash +curl -sS "http://127.0.0.1:8080/api/v1/checks//results" \ + -H "Authorization: Bearer " +``` + +## 5) Run the worker (async processing) + +```bash +export $(cat fastcheck_api/.env | xargs) +python -m fastcheck_api.app.workers.check_worker +``` diff --git a/fastcheck_api/requirements.txt b/fastcheck_api/requirements.txt new file mode 100644 index 0000000..853baf9 --- /dev/null +++ b/fastcheck_api/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.115,<1.0 +pydantic>=2.6,<3.0 +pydantic-settings>=2.2,<3.0 +email-validator>=2.2,<3.0 +uvicorn[standard]>=0.30,<1.0 +motor>=3.6,<4.0 +pymongo>=4.6,<5.0 +PyJWT>=2.8,<3.0 +bcrypt>=4.1,<5.0 +aio-pika>=9.4,<10.0 +httpx>=0.27,<1.0 diff --git a/fastcheck_api/run.sh b/fastcheck_api/run.sh new file mode 100755 index 0000000..ac7ce80 --- /dev/null +++ b/fastcheck_api/run.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +API_DIR="${ROOT_DIR}/fastcheck_api" +VENV_DIR="${ROOT_DIR}/.venv" + +cd "${ROOT_DIR}" + +if [[ ! -d "${VENV_DIR}" ]]; then + python3 -m venv "${VENV_DIR}" +fi + +source "${VENV_DIR}/bin/activate" + +python -m pip install --upgrade pip >/dev/null +python -m pip install -r "${API_DIR}/requirements.txt" + +if [[ -f "${API_DIR}/.env" ]]; then + set -a + source "${API_DIR}/.env" + set +a +elif [[ -f "${API_DIR}/.env.example" ]]; then + cp -n "${API_DIR}/.env.example" "${API_DIR}/.env" || true + set -a + source "${API_DIR}/.env" + set +a +fi + +HOST="${FASTCHECK_API_HOST:-127.0.0.1}" +PORT="${FASTCHECK_API_PORT:-8080}" + +exec uvicorn fastcheck_api.app.main:app --host "${HOST}" --port "${PORT}" --reload