455 lines
18 KiB
Python
455 lines
18 KiB
Python
"""Flask HTTP API over the billing core.
|
|
|
|
Consumed core interface (issue #1, ``gitea_saas/billing_core.py``):
|
|
|
|
Every operation returns ``(ok, details)`` and never raises for domain
|
|
failures; ``ok`` is bool and ``details`` is a dict. Domain errors carry
|
|
``details = {"error": {"code": "...", "message": "..."}}`` with codes
|
|
such as ``UNKNOWN_CLIENT``, ``INSUFFICIENT_BALANCE``, ``INVALID_AMOUNT``,
|
|
``HOLD_NOT_FOUND``, ``INTERNAL`` (see ``billing_core`` for the full set).
|
|
|
|
create_client(name, tier="free", status="active", meta=None)
|
|
-> {"id", "name", "tier",
|
|
"status", "meta", "created_at"}
|
|
get_client(client_id, include_balance=True)
|
|
-> {"client": {...}, "balance": N}
|
|
credit(client_id, amount_minor, idempotency_key,
|
|
currency="USD", ref_type=None, ref_id=None)
|
|
-> {"ledger_id", "client_id",
|
|
"balance", ...}
|
|
debit(...) -> same shape as credit
|
|
reserve_hold(client_id, amount_minor, idempotency_key,
|
|
ref_type=None, ref_id=None, expires_in_minutes=30)
|
|
-> {"hold_id", "client_id",
|
|
"amount_minor", "balance", ...}
|
|
release_hold(hold_id, idempotency_key)
|
|
-> {"hold_id", "released_at",
|
|
"balance", ...}
|
|
list_ledger(client_id, from_=None, to=None)
|
|
-> {"entries": [ledger row, ...],
|
|
"count": N}
|
|
list_clients() -> {"clients": [...], "count": N}
|
|
|
|
(Note: the returned client identifier is ``id`` since #16 merged; this API
|
|
speaks ``id`` on the wire and tolerates the pre-#16 ``client_id`` key in
|
|
core responses via ``_client_out``.)
|
|
|
|
Duplicate ``idempotency_key`` mutations are no-ops that return the original
|
|
result with ``ok=True`` and ``replayed=True`` (never an error). Balances and
|
|
amounts are integers in minor units.
|
|
|
|
The factory is deliberately pure HTTP: the core is injected, which keeps the
|
|
tests hermetic (Flask test client + temp DB) and free of any network/Stripe.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
from flask import Flask, jsonify, request
|
|
|
|
from .config import API_KEY_HEADER, load_config
|
|
|
|
|
|
class ApiError(Exception):
|
|
"""An error that maps to a concrete HTTP response."""
|
|
|
|
def __init__(self, status: int, code: str, message: str):
|
|
super().__init__(message)
|
|
self.status = status
|
|
self.code = code
|
|
self.message = message
|
|
|
|
|
|
def _core(app: Flask) -> Any:
|
|
return app.config["CORE"]
|
|
|
|
|
|
def _error(status: int, code: str, message: str):
|
|
payload = {"error": message, "code": code}
|
|
resp = jsonify(payload)
|
|
resp.status_code = status
|
|
return resp
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Core result handling
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _call(app: Flask, fn: str, **kwargs) -> Dict[str, Any]:
|
|
"""Invoke a core operation, enforcing the (ok, details) contract."""
|
|
try:
|
|
op = getattr(_core(app), fn)
|
|
except AttributeError:
|
|
raise ApiError(501, "not_implemented",
|
|
f"core does not implement {fn}()")
|
|
try:
|
|
result = op(**kwargs)
|
|
except TypeError as exc:
|
|
# Guard against core variants with different signatures surfacing as
|
|
# 500s instead of a clear 400/501.
|
|
raise ApiError(500, "core_contract", f"core.{fn}() failed: {exc}")
|
|
except Exception as exc: # noqa: BLE001 - fail closed, never leak internals
|
|
raise ApiError(500, "core_error", f"core.{fn}() raised: {exc!r}")
|
|
if not isinstance(result, tuple) or len(result) != 2:
|
|
raise ApiError(500, "core_contract",
|
|
f"core.{fn}() must return (ok, details)")
|
|
ok, details = result
|
|
if not ok:
|
|
raise _map_core_error(fn, details)
|
|
return details if isinstance(details, dict) else {"result": details}
|
|
|
|
|
|
def _map_core_error(fn: str, details: Any) -> ApiError:
|
|
"""Map a real-core error payload to an HTTP error.
|
|
|
|
The real ``BillingCore`` reports failures as
|
|
``(False, {"error": {"code": ..., "message": ...}})`` with codes such as
|
|
``UNKNOWN_CLIENT`` / ``INSUFFICIENT_BALANCE`` / ``INVALID_AMOUNT`` /
|
|
``HOLD_NOT_FOUND``. Translate those to HTTP statuses while keeping the
|
|
response body a plain ``{"error", "code"}`` pair.
|
|
"""
|
|
err = details.get("error") if isinstance(details, dict) else None
|
|
if isinstance(err, dict):
|
|
code = err.get("code")
|
|
message = err.get("message")
|
|
else:
|
|
# Defensive: tolerate the older flat {code, error} shape.
|
|
code = details.get("code") if isinstance(details, dict) else None
|
|
message = details.get("error") if isinstance(details, dict) else str(details)
|
|
message = message or f"{fn} failed"
|
|
|
|
status_map = {
|
|
"UNKNOWN_CLIENT": 404,
|
|
"HOLD_NOT_FOUND": 404,
|
|
"INSUFFICIENT_BALANCE": 409,
|
|
"DUPLICATE_CLIENT": 409,
|
|
"HOLD_ALREADY_RELEASED": 409,
|
|
"IDEMPOTENCY_CONFLICT": 409,
|
|
"INVALID_AMOUNT": 400,
|
|
"INVALID_KEY": 400,
|
|
"INVALID_CURRENCY": 400,
|
|
"INVALID_NAME": 400,
|
|
"INVALID_HOLD_TTL": 400,
|
|
"INTERNAL": 500,
|
|
}
|
|
if code in status_map:
|
|
return ApiError(status_map[code], code.lower(), message)
|
|
# Unknown structured errors are still surfaced (fail-closed), not 500'd.
|
|
if code:
|
|
return ApiError(400, code, message)
|
|
return ApiError(500, "core_error", message)
|
|
|
|
|
|
def _bad_request(message: str) -> ApiError:
|
|
return ApiError(400, "invalid_request", message)
|
|
|
|
|
|
def _require_int_field(body: Dict[str, Any], name: str, *, positive: bool = True) -> int:
|
|
value = body.get(name)
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise _bad_request(f"'{name}' must be an integer (minor units)")
|
|
if positive and value <= 0:
|
|
raise _bad_request(f"'{name}' must be a positive integer")
|
|
if not positive and value < 0:
|
|
raise _bad_request(f"'{name}' must be a non-negative integer")
|
|
return value
|
|
|
|
|
|
def _require_key(body: Dict[str, Any], name: str) -> str:
|
|
value = body.get(name)
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise _bad_request(f"'{name}' must be a non-empty string")
|
|
return value.strip()
|
|
|
|
|
|
def _optional_str(body: Dict[str, Any], name: str) -> Optional[str]:
|
|
value = body.get(name)
|
|
if value is None or value == "":
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise _bad_request(f"'{name}' must be a string")
|
|
return value
|
|
|
|
|
|
def _json_body() -> Dict[str, Any]:
|
|
body = request.get_json(silent=True)
|
|
if body is None or not isinstance(body, dict):
|
|
raise _bad_request("request body must be a JSON object")
|
|
return body
|
|
|
|
|
|
def _client_out(client: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Normalise a real-core client payload for the wire.
|
|
|
|
The real ``BillingCore`` returns two shapes:
|
|
* flat from ``create_client``: ``{client_id, name, tier, status,
|
|
meta, created_at}``
|
|
* nested from ``get_client``: ``{"client": {...}, "balance": N}``
|
|
|
|
This handles both and always exposes the API's ``id`` field (the core
|
|
has called it ``client_id`` pre-#16 and ``id`` since) plus the balance
|
|
when present. Unknown keys are
|
|
passed through rather than dropped, so a contract mismatch becomes a
|
|
visible test failure instead of silent data loss.
|
|
"""
|
|
if not isinstance(client, dict):
|
|
return client
|
|
# Unwrap the nested get_client shape.
|
|
inner = client.get("client")
|
|
if isinstance(inner, dict):
|
|
out = dict(inner)
|
|
if "balance" in client:
|
|
out["balance"] = client["balance"]
|
|
client = out
|
|
out = dict(client)
|
|
if "client_id" in out and "id" not in out:
|
|
out["id"] = out.pop("client_id")
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# App factory
|
|
# --------------------------------------------------------------------------
|
|
|
|
def create_app(core: Any, api_key: str) -> Flask:
|
|
"""Build the Flask app around a billing ``core`` instance.
|
|
|
|
``api_key`` is matched against the ``X-API-Key`` header on every route
|
|
except ``/health``. Missing or wrong keys get ``401``.
|
|
"""
|
|
app = Flask(__name__)
|
|
app.config["CORE"] = core
|
|
app.config["API_KEY"] = api_key or "" # empty -> deny all (fail-closed)
|
|
|
|
@app.before_request
|
|
def _require_api_key():
|
|
if request.path.rstrip("/") == "/health":
|
|
return None
|
|
sent = request.headers.get(API_KEY_HEADER, "")
|
|
expected = app.config["API_KEY"]
|
|
if not expected or not isinstance(sent, str) or not hmac.compare_digest(
|
|
sent, expected):
|
|
return _error(401, "unauthorized", "missing or invalid API key")
|
|
return None
|
|
|
|
@app.errorhandler(ApiError)
|
|
def _handle_api_error(exc: ApiError):
|
|
return _error(exc.status, exc.code, exc.message)
|
|
|
|
@app.errorhandler(404)
|
|
def _handle_404(exc):
|
|
return _error(404, "not_found", "resource not found")
|
|
|
|
# -- public ----------------------------------------------------------
|
|
@app.get("/health")
|
|
def health():
|
|
return jsonify({"ok": True})
|
|
|
|
# -- clients ---------------------------------------------------------
|
|
@app.post("/api/clients")
|
|
def create_client():
|
|
body = _json_body()
|
|
name = _require_key(body, "name")
|
|
tier = _optional_str(body, "tier")
|
|
meta = body.get("meta")
|
|
kwargs: Dict[str, Any] = {"name": name}
|
|
if tier is not None:
|
|
kwargs["tier"] = tier
|
|
if meta is not None:
|
|
kwargs["meta"] = meta
|
|
created = _call(app, "create_client", **kwargs)
|
|
client_id = created.get("client_id") or created.get("id")
|
|
# Re-fetch through the real core so the response carries the API's
|
|
# `id` field plus the current balance (the real create_client does
|
|
# not return a balance).
|
|
client = _call(app, "get_client", client_id=client_id, include_balance=True)
|
|
resp = jsonify(_client_out(client))
|
|
resp.status_code = 201
|
|
return resp
|
|
|
|
@app.get("/api/clients/<int:client_id>")
|
|
def get_client(client_id: int):
|
|
client = _call(app, "get_client", client_id=client_id, include_balance=True)
|
|
return jsonify(_client_out(client))
|
|
|
|
# -- money movement --------------------------------------------------
|
|
@app.post("/api/clients/<int:client_id>/credits")
|
|
def add_credit(client_id: int):
|
|
body = _json_body()
|
|
return jsonify(_call(app, "credit",
|
|
client_id=client_id,
|
|
amount_minor=_require_int_field(body, "amount_minor"),
|
|
idempotency_key=_require_key(body, "idempotency_key"),
|
|
ref_type=_optional_str(body, "ref_type"),
|
|
ref_id=_optional_str(body, "ref_id")))
|
|
|
|
@app.post("/api/clients/<int:client_id>/debits")
|
|
def add_debit(client_id: int):
|
|
body = _json_body()
|
|
return jsonify(_call(app, "debit",
|
|
client_id=client_id,
|
|
amount_minor=_require_int_field(body, "amount_minor"),
|
|
idempotency_key=_require_key(body, "idempotency_key"),
|
|
ref_type=_optional_str(body, "ref_type"),
|
|
ref_id=_optional_str(body, "ref_id")))
|
|
|
|
# -- holds -----------------------------------------------------------
|
|
@app.post("/api/clients/<int:client_id>/holds")
|
|
def reserve_hold(client_id: int):
|
|
body = _json_body()
|
|
kwargs: Dict[str, Any] = {
|
|
"client_id": client_id,
|
|
"amount_minor": _require_int_field(body, "amount_minor"),
|
|
"idempotency_key": _require_key(body, "idempotency_key"),
|
|
"ref_type": _optional_str(body, "ref_type"),
|
|
"ref_id": _optional_str(body, "ref_id"),
|
|
}
|
|
ttl = body.get("ttl_s")
|
|
if ttl is not None:
|
|
if isinstance(ttl, bool) or not isinstance(ttl, (int, float)) or ttl <= 0:
|
|
raise _bad_request("'ttl_s' must be a positive number (seconds)")
|
|
# Real core takes expires_in_minutes (a float, minutes).
|
|
kwargs["expires_in_minutes"] = ttl / 60.0
|
|
held = _call(app, "reserve_hold", **kwargs)
|
|
resp = jsonify(held)
|
|
resp.status_code = 201
|
|
return resp
|
|
|
|
@app.post("/api/holds/<int:hold_id>/release")
|
|
def release_hold(hold_id: int):
|
|
body = _json_body()
|
|
return jsonify(_call(app, "release_hold",
|
|
hold_id=hold_id,
|
|
idempotency_key=_require_key(body, "idempotency_key")))
|
|
|
|
# -- ledger ----------------------------------------------------------
|
|
@app.get("/api/clients/<int:client_id>/ledger")
|
|
def ledger(client_id: int):
|
|
from_ = request.args.get("from")
|
|
to_ = request.args.get("to")
|
|
result = _call(app, "list_ledger", client_id=client_id,
|
|
from_=from_ or None, to=to_ or None)
|
|
entries = result.get("entries", []) if isinstance(result, dict) else []
|
|
return jsonify({"rows": entries, "count": len(list(entries))})
|
|
|
|
return app
|
|
|
|
|
|
def _build_core(cfg):
|
|
"""Construct the real billing core from config (issue #1 module)."""
|
|
from gitea_saas import billing_core # lazy import: pure-server may omit it
|
|
db_path = cfg.db_path or Path(":memory:")
|
|
for ctor_name in ("BillingCore", "Core", "LedgerCore"):
|
|
ctor = getattr(billing_core, ctor_name, None)
|
|
if ctor is not None:
|
|
return ctor(db_path)
|
|
raise RuntimeError(
|
|
"gitea_saas.billing_core has no known constructor "
|
|
"(expected BillingCore/Core/LedgerCore)"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""Entrypoint for ``python -m gitea_saas.server``."""
|
|
cfg = load_config()
|
|
if not cfg.api_key:
|
|
sys.exit("SAAS_API_KEY is unset; refusing to start (fail-closed).")
|
|
core = _build_core(cfg)
|
|
app = create_app(core, cfg.api_key)
|
|
# Issue #6: mount the review loop onto the same app (same X-API-Key
|
|
# guard) instead of defining a second server.
|
|
policy = build_review_policy()
|
|
mount_review_routes(app, policy)
|
|
# Deployed behind a reverse proxy in practice; still default to loopback.
|
|
app.run(host="127.0.0.1", port=cfg.port)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Review-gate wiring (issue #6) — mounted onto the billing app
|
|
# --------------------------------------------------------------------------
|
|
|
|
def mount_review_routes(app: Flask, policy: Any,
|
|
default_repo: Optional[str] = None) -> Flask:
|
|
"""Register the review-loop endpoints on the billing ``app``.
|
|
|
|
The review policy is mounted onto the issue-#2 app factory rather than
|
|
defining a second Flask app: the routes inherit the app's ``X-API-Key``
|
|
``before_request`` guard (fail-closed — an unset key denies everything).
|
|
``policy`` is a ``ReviewPolicy`` (or fake).
|
|
|
|
Endpoints:
|
|
* POST /api/review/poll — manual trigger: run the policy loop for
|
|
all open in-scope PRs (body: {"repo": "owner/name"} optional).
|
|
* POST /api/webhooks/gitea — Gitea merge-event webhook; on a merged
|
|
pull_request it runs the delivery debit + receipt via
|
|
``ReviewPolicy.handle_merge_event``.
|
|
"""
|
|
app.config["POLICY"] = policy
|
|
app.config["DEFAULT_REPO"] = default_repo
|
|
|
|
@app.post("/api/review/poll")
|
|
def review_poll():
|
|
payload = request.get_json(silent=True) or {}
|
|
target = payload.get("repo") or app.config.get("DEFAULT_REPO")
|
|
results = app.config["POLICY"].poll_and_review(target)
|
|
return jsonify({"results": results})
|
|
|
|
@app.post("/api/webhooks/gitea")
|
|
def gitea_webhook():
|
|
payload = request.get_json(silent=True)
|
|
if not isinstance(payload, dict):
|
|
return _error(400, "bad_payload", "expected JSON")
|
|
pr = payload.get("pull_request")
|
|
if not isinstance(pr, dict) or pr.get("number") is None:
|
|
return _error(400, "bad_payload",
|
|
"payload has no pull_request.number")
|
|
pr_number = int(pr["number"])
|
|
merged = bool(pr.get("merged")) or payload.get("action") == "merged"
|
|
if not merged:
|
|
return jsonify({"handled": False, "reason": "not a merge event"}), 200
|
|
ok, details = app.config["POLICY"].handle_merge_event(
|
|
payload.get("repository", {}).get("full_name")
|
|
or app.config.get("DEFAULT_REPO") or "",
|
|
pr_number)
|
|
return jsonify({"handled": True, "ok": ok, "details": details}), 200
|
|
|
|
return app
|
|
|
|
|
|
def build_review_policy(config: Optional[dict] = None):
|
|
"""Assemble a default ``ReviewPolicy`` from env (issue #6).
|
|
|
|
Used by ``main()`` so the review loop runs in the same process as the
|
|
billing API. The Gitea client seam is the canonical module owned by
|
|
issue #4 — it lands with that PR, which is why the import is lazy and
|
|
fails loudly (never silently) when the module is absent.
|
|
"""
|
|
from .billing_core import BillingCore
|
|
from .config import load_review_config
|
|
from .review_policy import ReviewPolicy
|
|
from .reviewer import Reviewer, Runner
|
|
|
|
cfg = load_review_config() if config is None else config
|
|
try:
|
|
from .gitea_client import GiteaClient
|
|
except ImportError as exc: # pragma: no cover - branch state, not runtime
|
|
raise RuntimeError(
|
|
"gitea_saas.gitea_client is not on this branch: the canonical "
|
|
"client is owned by issue #4 and lands via its PR. Deploy after "
|
|
"#4 merges, or pass a GiteaClient explicitly."
|
|
) from exc
|
|
db_path = cfg.get("db_path")
|
|
core = BillingCore(db_path if db_path else ":memory:") # type: ignore[arg-type]
|
|
gitea = GiteaClient()
|
|
reviewer = Reviewer(gitea, Runner(timeout_s=cfg["test_timeout_s"]),
|
|
config=cfg)
|
|
return ReviewPolicy(core, gitea, reviewer, cfg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|