API client

This commit is contained in:
valenti 2026-04-16 12:05:29 -04:00
parent 97433ff68b
commit b8bd410eeb
45 changed files with 1938 additions and 0 deletions

View File

@ -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

View File

@ -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

40
fastcheck_api/README.md Normal file
View File

@ -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
```

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -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

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -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 <jwt>'",
},
{
"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 <jwt>'\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}

View File

@ -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 <jwt>' \\\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 <jwt>'}},\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 <jwt>',\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/<job_id>/results?page=1&limit=10' \\\n -H 'Authorization: Bearer <jwt>'",
},
{
"lang": "python",
"label": "httpx",
"source": f"import httpx\nresp = httpx.get('http://127.0.0.1:8181{API_PREFIX}/checks/<job_id>/results',\n params={{'page': 1, 'limit': 10}},\n headers={{'Authorization': 'Bearer <jwt>'}})\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))

View File

@ -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])

View File

@ -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}

View File

@ -0,0 +1 @@

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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<count>\d+)(?P<unit>[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

134
fastcheck_api/app/main.py Normal file
View File

@ -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(
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FastCheck API Docs</title>
<style>
html, body { height: 100%; margin: 0; }
</style>
</head>
<body>
<rapi-doc
spec-url="/openapi.json"
render-style="read"
show-header="false"
allow-try="true"
allow-authentication="true"
allow-server-selection="true"
show-components="true"
show-info="true"
show-side-nav="true"
theme="dark"
primary-color="#4f46e5"
></rapi-doc>
<script src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
</body>
</html>
""".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)

View File

@ -0,0 +1 @@

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1 @@

View File

@ -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=["<jwt>"])
user: UserOut

View File

@ -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])

View File

@ -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)

View File

@ -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

View File

@ -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])

View File

@ -0,0 +1 @@

View File

@ -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)

View File

@ -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 ""),
},
}

View File

@ -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}

View File

@ -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()

View File

@ -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()

View File

@ -0,0 +1 @@

View File

@ -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

View File

@ -0,0 +1 @@

View File

@ -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()

View File

@ -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.

View File

@ -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 <jwt>
```
## Token refresh
```bash
curl -sS -X POST "http://127.0.0.1:8080/api/v1/auth/refresh" \
-H "Authorization: Bearer <jwt>"
```

View File

@ -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());
```

View File

@ -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)

View File

@ -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 <jwt>" \
-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/<job_id>/results" \
-H "Authorization: Bearer <jwt>"
```
## 5) Run the worker (async processing)
```bash
export $(cat fastcheck_api/.env | xargs)
python -m fastcheck_api.app.workers.check_worker
```

View File

@ -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

33
fastcheck_api/run.sh Executable file
View File

@ -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