"""Public API v2 — API keys, rate-limited read-only endpoints.""" from __future__ import annotations from datetime import datetime, timezone from typing import Any, Optional from fastapi import APIRouter, Depends, Header, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session from core.subscription.api_keys import ( create_api_key, list_api_keys, revoke_api_key, verify_api_key_hash, ) from core.subscription.middleware import verify_token from endpoints.projects import get_db router = APIRouter(prefix="/api/v2", tags=["public-api-v2"]) public_router = APIRouter(prefix="/api/v2/public", tags=["public-api-v2"]) class ApiKeyCreateBody(BaseModel): label: str = Field(..., min_length=1, max_length=80) def _tenant_id(token_data: dict) -> str: return token_data.get("sub") or token_data.get("user_id") or "anonymous" async def require_api_key( x_api_key: Optional[str] = Header(None, alias="X-API-Key"), db: Session = Depends(get_db), ) -> dict[str, Any]: if not x_api_key or len(x_api_key) < 16: raise HTTPException(status_code=401, detail="Brak lub nieprawidłowy nagłówek X-API-Key") record = verify_api_key_hash(db, x_api_key) if not record: raise HTTPException(status_code=401, detail="Nieprawidłowy klucz API") return record @router.post("/developer/keys") async def issue_api_key( body: ApiKeyCreateBody, token_data: dict = Depends(verify_token), db: Session = Depends(get_db), ): clerk_id = _tenant_id(token_data) raw_key, meta = create_api_key(db, clerk_id, body.label) return {"api_key": raw_key, "key": meta} @router.get("/developer/keys") async def get_api_keys( token_data: dict = Depends(verify_token), db: Session = Depends(get_db), ): clerk_id = _tenant_id(token_data) return {"keys": list_api_keys(db, clerk_id)} @router.delete("/developer/keys/{key_id}") async def delete_api_key( key_id: str, token_data: dict = Depends(verify_token), db: Session = Depends(get_db), ): clerk_id = _tenant_id(token_data) if not revoke_api_key(db, key_id, clerk_id): raise HTTPException(status_code=404, detail="Klucz nie znaleziony") return {"status": "revoked", "id": key_id} @public_router.get("/openapi.json") async def public_openapi_spec(): """Minimal OpenAPI subset for public integrators.""" return { "openapi": "3.1.0", "info": { "title": "GrantForge Public API v2", "version": "2.0.0", "description": "Read-only endpoints for grant catalog and trust signals.", }, "servers": [{"url": "/api/v2/public"}], "paths": { "/grants/nabory": { "get": { "summary": "List grant calls", "parameters": [ {"name": "q", "in": "query", "schema": {"type": "string"}}, {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 20}}, ], "responses": {"200": {"description": "Grant list"}}, } }, "/trust/summary": { "get": { "summary": "Platform trust score summary", "responses": {"200": {"description": "Trust summary"}}, } }, "/regional/programs": { "get": { "summary": "Verified regional RPO programs", "parameters": [ { "name": "voivodeship", "in": "query", "schema": {"type": "string"}, }, { "name": "limit", "in": "query", "schema": {"type": "integer", "default": 50}, }, ], "responses": {"200": {"description": "Regional program list"}}, } }, "/health": { "get": { "summary": "API health", "responses": {"200": {"description": "OK"}}, } }, }, "components": { "securitySchemes": { "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"} } }, "security": [{"ApiKeyAuth": []}], } @public_router.get("/health") async def public_health(_key: dict = Depends(require_api_key)): return { "status": "ok", "version": "2.0.0", "authenticated_as": _key.get("user_id"), "checked_at": datetime.now(timezone.utc).isoformat(), } @public_router.get("/grants/nabory") async def public_grants_nabory( q: Optional[str] = Query(None), limit: int = Query(20, ge=1, le=100), _key: dict = Depends(require_api_key), db: Session = Depends(get_db), ): from core.grants.models import Grant query = db.query(Grant) if q: like = f"%{q.strip()}%" query = query.filter(Grant.name.ilike(like) | Grant.operator.ilike(like)) rows = query.order_by(Grant.fetched_at.desc()).limit(limit).all() return { "count": len(rows), "nabory": [ { "id": g.id, "name": g.name, "operator": g.operator, "status": g.status, "regions": g.eligible_regions, "url": g.url or g.official_page_url, } for g in rows ], } @public_router.get("/trust/summary") async def public_trust_summary( _key: dict = Depends(require_api_key), db: Session = Depends(get_db), ): from core.trust.trust_center import get_trust_center_summary summary = get_trust_center_summary(db) return { "platform_score": summary.get("platform_score"), "level": summary.get("level"), "components": summary.get("components"), "recommendations": summary.get("recommendations", [])[:5], "checked_at": datetime.now(timezone.utc).isoformat(), } @public_router.get("/regional/programs") async def public_regional_programs( voivodeship: Optional[str] = Query(None, max_length=50), limit: int = Query(50, ge=1, le=200), _key: dict = Depends(require_api_key), ): from core.grants.regional_catalog import get_voivodeships, list_regional_programs from core.grants.rpo_bip_parsers import get_cached_bip_programs, list_bip_voivodeships programs = list_regional_programs(voivodeship=voivodeship, serveable_only=True)[:limit] bip_live = get_cached_bip_programs(voivodeship)[: max(0, limit - len(programs))] return { "count": len(programs) + len(bip_live), "programs": programs, "bip_live": bip_live, "voivodeships": get_voivodeships(), "bip_parser_voivodeships": list_bip_voivodeships(), "credibility_filtered": True, }