API client
This commit is contained in:
parent
97433ff68b
commit
b8bd410eeb
19
fastcheck_api/.env copy.example
Normal file
19
fastcheck_api/.env copy.example
Normal 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
|
||||||
20
fastcheck_api/.env.example
Normal file
20
fastcheck_api/.env.example
Normal 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
40
fastcheck_api/README.md
Normal 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
|
||||||
|
```
|
||||||
1
fastcheck_api/__init__.py
Normal file
1
fastcheck_api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
1
fastcheck_api/app/__init__.py
Normal file
1
fastcheck_api/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
1
fastcheck_api/app/api/__init__.py
Normal file
1
fastcheck_api/app/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
191
fastcheck_api/app/api/dependencies.py
Normal file
191
fastcheck_api/app/api/dependencies.py
Normal 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
|
||||||
1
fastcheck_api/app/api/v1/__init__.py
Normal file
1
fastcheck_api/app/api/v1/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
1
fastcheck_api/app/api/v1/routers/__init__.py
Normal file
1
fastcheck_api/app/api/v1/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
101
fastcheck_api/app/api/v1/routers/auth.py
Normal file
101
fastcheck_api/app/api/v1/routers/auth.py
Normal 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}
|
||||||
169
fastcheck_api/app/api/v1/routers/checks.py
Normal file
169
fastcheck_api/app/api/v1/routers/checks.py
Normal 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))
|
||||||
37
fastcheck_api/app/api/v1/routers/reports.py
Normal file
37
fastcheck_api/app/api/v1/routers/reports.py
Normal 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])
|
||||||
59
fastcheck_api/app/api/v1/routers/usage.py
Normal file
59
fastcheck_api/app/api/v1/routers/usage.py
Normal 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}
|
||||||
1
fastcheck_api/app/core/__init__.py
Normal file
1
fastcheck_api/app/core/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
75
fastcheck_api/app/core/config.py
Normal file
75
fastcheck_api/app/core/config.py
Normal 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()
|
||||||
25
fastcheck_api/app/core/logging.py
Normal file
25
fastcheck_api/app/core/logging.py
Normal 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)
|
||||||
28
fastcheck_api/app/core/mongodb.py
Normal file
28
fastcheck_api/app/core/mongodb.py
Normal 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()
|
||||||
65
fastcheck_api/app/core/security.py
Normal file
65
fastcheck_api/app/core/security.py
Normal 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
134
fastcheck_api/app/main.py
Normal 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)
|
||||||
1
fastcheck_api/app/middleware/__init__.py
Normal file
1
fastcheck_api/app/middleware/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
37
fastcheck_api/app/middleware/request_logging.py
Normal file
37
fastcheck_api/app/middleware/request_logging.py
Normal 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
|
||||||
11
fastcheck_api/app/middleware/tenant_context.py
Normal file
11
fastcheck_api/app/middleware/tenant_context.py
Normal 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)
|
||||||
1
fastcheck_api/app/schemas/__init__.py
Normal file
1
fastcheck_api/app/schemas/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
23
fastcheck_api/app/schemas/auth.py
Normal file
23
fastcheck_api/app/schemas/auth.py
Normal 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
|
||||||
57
fastcheck_api/app/schemas/checks.py
Normal file
57
fastcheck_api/app/schemas/checks.py
Normal 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])
|
||||||
8
fastcheck_api/app/schemas/common.py
Normal file
8
fastcheck_api/app/schemas/common.py
Normal 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)
|
||||||
17
fastcheck_api/app/schemas/reports.py
Normal file
17
fastcheck_api/app/schemas/reports.py
Normal 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
|
||||||
35
fastcheck_api/app/schemas/usage.py
Normal file
35
fastcheck_api/app/schemas/usage.py
Normal 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])
|
||||||
1
fastcheck_api/app/services/__init__.py
Normal file
1
fastcheck_api/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
64
fastcheck_api/app/services/audit_service.py
Normal file
64
fastcheck_api/app/services/audit_service.py
Normal 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)
|
||||||
47
fastcheck_api/app/services/auth_service.py
Normal file
47
fastcheck_api/app/services/auth_service.py
Normal 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 ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
135
fastcheck_api/app/services/check_service.py
Normal file
135
fastcheck_api/app/services/check_service.py
Normal 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}
|
||||||
45
fastcheck_api/app/services/legacy_service.py
Normal file
45
fastcheck_api/app/services/legacy_service.py
Normal 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()
|
||||||
47
fastcheck_api/app/services/rabbitmq_service.py
Normal file
47
fastcheck_api/app/services/rabbitmq_service.py
Normal 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()
|
||||||
1
fastcheck_api/app/utils/__init__.py
Normal file
1
fastcheck_api/app/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
23
fastcheck_api/app/utils/mongo.py
Normal file
23
fastcheck_api/app/utils/mongo.py
Normal 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
|
||||||
1
fastcheck_api/app/workers/__init__.py
Normal file
1
fastcheck_api/app/workers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
148
fastcheck_api/app/workers/check_worker.py
Normal file
148
fastcheck_api/app/workers/check_worker.py
Normal 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()
|
||||||
19
fastcheck_api/docs/api_reference.md
Normal file
19
fastcheck_api/docs/api_reference.md
Normal 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.
|
||||||
62
fastcheck_api/docs/authentication.md
Normal file
62
fastcheck_api/docs/authentication.md
Normal 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>"
|
||||||
|
```
|
||||||
85
fastcheck_api/docs/examples.md
Normal file
85
fastcheck_api/docs/examples.md
Normal 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());
|
||||||
|
```
|
||||||
12
fastcheck_api/docs/index.md
Normal file
12
fastcheck_api/docs/index.md
Normal 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)
|
||||||
45
fastcheck_api/docs/quickstart.md
Normal file
45
fastcheck_api/docs/quickstart.md
Normal 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
|
||||||
|
```
|
||||||
11
fastcheck_api/requirements.txt
Normal file
11
fastcheck_api/requirements.txt
Normal 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
33
fastcheck_api/run.sh
Executable 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
|
||||||
Loading…
Reference in New Issue
Block a user