grantforge-api / backend /endpoints /public_api_v2.py
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
7.09 kB
"""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,
}