auradefi 0.1.1
PyPI GitHub

How do I expose this over HTTP, the way Plaid clients already expect?

Run it

pip install 'auradefi[api]'
python examples/05_serve_the_http_api.py

create_app(Deps(...)) returns a FastAPI app. It is a shell: it holds no state, opens no connections and invents no stores: you inject the same ports the library uses (04_persist_to_your_database.py) and the routes project them onto Plaid's wire format.

This file drives the app with FastAPI's TestClient so it runs without a server, then shows the one-liner that serves it for real. The journey is the one a client actually makes:

POST /auth/token          server key  -> short-lived user token
POST /connections         user token  -> conn_… (409 names the existing one)
GET  /crypto/sync         user token  -> added/modified/removed + cursor
POST /batch/holdings      server key  -> partial success, per-item errors
GET  /coverage            public      -> generated capability matrix

Ingestion is NOT an HTTP concern: rows arrive in the ledger from your own worker calling the library (02_embed_in_your_backend.py). This file seeds them directly so the sync feed has something to page.

What it prints

project proj_fa3ddbfd8f5c8fb1: routes:
  POST  /auth/revoke
  POST  /auth/token
  POST  /batch/holdings
  GET   /connections
  POST  /connections
  GET   /connections/{connection_id}
  GET   /coverage
  GET   /crypto/sync
  GET   /users
  GET   /users/me
  GET   /webhooks/dead_letter
  GET   /webhooks/deliveries
  POST  /webhooks/deliveries/{delivery_id}/replay
  GET   /webhooks/endpoints
  POST  /webhooks/endpoints

minted a user token from adk_live_55ad…
  quota headers: 9 (second/minute/day x limit/remaining/reset)
  audit: token.minted by key_1ecad29292c270e0 from testclient

POST /connections -> 201 conn_ac0ee6388b10039d
  again -> 409 connection already exists: 'conn_ac0ee6388b10039d'

GET /crypto/sync: 2 pages, 3 transactions, cursor 00000000000000000003
  quantity on the wire: {'raw': '300000000000000000', 'decimals': 18, 'numeric': '0.3', 'float': 0.3}
  `raw` is a STRING even here: a JS client cannot round it by accident
  bad cursor -> 422 CursorError

POST /batch/holdings -> 200, items in request order: ['ok', 'error']
  item 0: {'amount': '6000', 'currency': 'USD'}
  item 1: UnknownChainError: one bad item never fails the request,
          and index i of the response always answers index i of the request

GET /coverage: 5 chains, generated from Deps:
  eip155:1: balances, prices, transactions
  (positions=False because no reader is bound: the matrix cannot flatter us)

OK: Plaid's envelope over your ports, and nothing else.

The whole file

05_serve_the_http_api.py on GitHub: self-contained, offline, asserts its own output.

"""How do I expose this over HTTP, the way Plaid clients already expect?

    pip install 'auradefi[api]'
    python examples/05_serve_the_http_api.py

`create_app(Deps(...))` returns a FastAPI app. It is a shell: it holds no
state, opens no connections and invents no stores: you inject the same
ports the library uses (`04_persist_to_your_database.py`) and the routes
project them onto Plaid's wire format.

This file drives the app with FastAPI's `TestClient` so it runs without a
server, then shows the one-liner that serves it for real. The journey is
the one a client actually makes:

    POST /auth/token          server key  -> short-lived user token
    POST /connections         user token  -> conn_… (409 names the existing one)
    GET  /crypto/sync         user token  -> added/modified/removed + cursor
    POST /batch/holdings      server key  -> partial success, per-item errors
    GET  /coverage            public      -> generated capability matrix

Ingestion is NOT an HTTP concern: rows arrive in the ledger from your own
worker calling the library (`02_embed_in_your_backend.py`). This file seeds
them directly so the sync feed has something to page.
"""

from __future__ import annotations

from decimal import Decimal

from fastapi.testclient import TestClient

from auradefi.api.app import create_app
from auradefi.api.deps import Deps
from auradefi.chains.registry import ChainRegistry
from auradefi.clock import FrozenClock
from auradefi.ledger.backends.memory import MemoryLedger
from auradefi.ledger.models import Direction, Entry, LedgerTransaction, transaction_id
from auradefi.money.fiat import Money
from auradefi.money.quantity import Quantity
from auradefi.portfolio.models import Holding, HoldingsReport
from auradefi.tenancy.audit import AuditLog
from auradefi.tenancy.keys import ApiKeyStore
from auradefi.tenancy.models import Environment, Scope, end_user_id
from auradefi.tenancy.quota import QuotaCounter, QuotaLimits
from auradefi.tenancy.store import TenancyStore
from auradefi.tenancy.tokens import RevocationSet
from auradefi.webhooks.deliver import WebhookStore

CHAIN, ETH = "eip155:1", "eip155:1/slip44:60"
ADDRESS = "0xAAAAaaaaAAAAaaaaAAAAaaaaAAAAaaaaAAAAaaaa"
NOW = 1_754_000_000_000


class StubHoldings:
    """Whatever answers `holdings(chain_id, address)`. Bind yours, or
    leave `Deps.holdings=None` and the batch route is not mounted at all.
    An unbound capability has no endpoint rather than a broken one."""

    def holdings(self, chain_id: str, address: str) -> HoldingsReport:
        return HoldingsReport.assemble(address, chain_id, [
            Holding(caip19=ETH, symbol="ETH", quantity=Quantity(2 * 10**18, 18),
                    price=Money(Decimal("3000"), "USD"),
                    value=Money(Decimal("6000"), "USD")),
        ], NOW)


# --------------------------------------------------------- 1. build the app
clock = FrozenClock(NOW)
tenancy = TenancyStore()
organisation = tenancy.create_organisation("acme", clock)
project = tenancy.create_project(organisation.id, "main", Environment.LIVE, clock)
ledger = MemoryLedger()

deps = Deps(
    tenancy=tenancy,
    keys=ApiKeyStore(),
    quota=QuotaCounter(QuotaLimits(1_000, 10_000, 100_000), clock),
    audit=AuditLog(),
    revocations=RevocationSet(),
    ledger=ledger,
    webhooks=WebhookStore(),
    chains=ChainRegistry(),
    clock=clock,
    signing_secret_for={project.id: project.signing_secret}.get,
    holdings=StubHoldings(),
    # `/coverage` is generated from THIS, never from prose. Declare only
    # what you actually wired up.
    capabilities={CHAIN: frozenset({"balances", "transactions", "prices"})},
)
client = TestClient(create_app(deps))
print(f"project {project.id}: routes:")
schema = client.get("/openapi.json").json()
for path in sorted(schema["paths"]):
    for method in sorted(schema["paths"][path]):
        print(f"  {method.upper():<5} {path}")

# ------------------------------------------- 2. server key -> user token
# The server key never leaves your backend. It mints a short-lived token
# scoped to ONE end user, which is what a browser or mobile app may hold.
key, secret = deps.keys.issue(
    project.id, Environment.LIVE,
    (Scope.USERS_ADMIN, Scope.ACCOUNTS_READ, Scope.ACCOUNTS_WRITE), clock,
)
server_auth = {"Authorization": f"Bearer {secret}"}
assert secret.startswith("adk_live_")

minted = client.post("/auth/token", json={"external_user_id": "host-user-7"},
                     headers=server_auth)
assert minted.status_code == 200 and list(minted.json()) == ["token"]
user_auth = {"Authorization": f"Bearer {minted.json()['token']}"}

quota_headers = {name.lower(): value for name, value in minted.headers.items()
                 if name.lower().startswith("x-ratelimit")}
assert len(quota_headers) == 9          # 3 windows x limit/remaining/reset
print(f"\nminted a user token from {secret[:13]}…")
print(f"  quota headers: {len(quota_headers)} "
      f"(second/minute/day x limit/remaining/reset)")
print(f"  audit: {deps.audit.entries(project.id)[0].event} "
      f"by {deps.audit.entries(project.id)[0].key_id} "
      f"from {deps.audit.entries(project.id)[0].ip}")

# ---------------------------------------------------- 3. connect a wallet
created = client.post("/connections", json={"kind": "address", "descriptor": ADDRESS},
                      headers=user_auth)
assert created.status_code == 201
connection_id = created.json()["id"]

# The same wallet again, in different case, is a 409 that NAMES the
# connection you already have, so a retrying client can carry on.
conflict = client.post("/connections",
                       json={"kind": "address", "descriptor": ADDRESS.lower()},
                       headers=user_auth)
assert conflict.status_code == 409
assert conflict.json()["error"]["existing_connection_id"] == connection_id
print(f"\nPOST /connections -> 201 {connection_id}")
print(f"  again -> {conflict.status_code} {conflict.json()['error']['message']}")

# ------------------------------------------------------ 4. the sync feed
# Seeded here; in production your worker wrote these rows.
tenant = end_user_id(project.id, "host-user-7")
ledger.upsert(tenant, [
    LedgerTransaction(
        id=transaction_id(CHAIN, "0x" + f"{index:02x}" * 32, "acct_eth"),
        chain_id=CHAIN, tx_hash="0x" + f"{index:02x}" * 32, account_id="acct_eth",
        block_number=18_000_000 + index,
        initiated_at=NOW - 10_000 + index, confirmed_at=NOW - 9_000 + index,
        entries=(Entry(asset_id=ETH, quantity=Quantity(index * 10**17, 18),
                       direction=Direction.IN),),
    ) for index in (1, 2, 3)
])

seen, cursor, pages = [], None, 0
while True:
    query = "/crypto/sync?limit=2" + (f"&cursor={cursor}" if cursor else "")
    page = client.get(query, headers=user_auth).json()
    pages += 1
    assert set(page) == {"added", "modified", "removed", "next_cursor", "has_more"}
    seen += [row["transaction_id"] for row in page["added"]]
    cursor = page["next_cursor"]
    if not page["has_more"]:
        break

assert (pages, len(seen)) == (2, 3)
quantity = page["added"][0]["entries"][0]["quantity"]
assert quantity == {"raw": "300000000000000000", "decimals": 18,
                    "numeric": "0.3", "float": 0.3}
print(f"\nGET /crypto/sync: {pages} pages, {len(seen)} transactions, cursor {cursor}")
print(f"  quantity on the wire: {quantity}")
print("  `raw` is a STRING even here: a JS client cannot round it by accident")

# A bad cursor is a 422 and costs the caller no quota: a client with a
# hard-coded bad parameter cannot drain the project's daily window.
bad = client.get("/crypto/sync?cursor=not-a-cursor", headers=user_auth)
assert bad.status_code == 422
print(f"  bad cursor -> {bad.status_code} {bad.json()['error']['type']}")

# ------------------------------------------------------ 5. batch holdings
batch = client.post("/batch/holdings", json={"items": [
    {"chain": CHAIN, "address": ADDRESS},
    {"chain": "eip155:99999", "address": ADDRESS},        # unknown chain
]}, headers=server_auth)
assert batch.status_code == 200                          # partial success
items = batch.json()["items"]
assert [item["status"] for item in items] == ["ok", "error"]
assert items[0]["result"]["total_value"] == {"amount": "6000", "currency": "USD"}
assert items[1]["error"]["type"] == "UnknownChainError"
print(f"\nPOST /batch/holdings -> {batch.status_code}, items in request order: "
      f"{[item['status'] for item in items]}")
print(f"  item 0: {items[0]['result']['total_value']}")
print(f"  item 1: {items[1]['error']['type']}: one bad item never fails the request,"
      "\n          and index i of the response always answers index i of the request")

# ---------------------------------------------------------- 6. coverage
coverage = client.get("/coverage").json()               # public, no auth
row = next(entry for entry in coverage["chains"] if entry["chain_id"] == CHAIN)
assert row["capabilities"] == {"balances": True, "transactions": True,
                               "positions": False, "prices": True, "xpub": False}
print(f"\nGET /coverage: {len(coverage['chains'])} chains, generated from Deps:")
print(f"  {CHAIN}: " + ", ".join(sorted(name for name, on in row["capabilities"].items() if on)))
print("  (positions=False because no reader is bound: the matrix cannot flatter us)")

# ------------------------------------------------------------- 7. serve it
# In a file called `main.py`:
#
#     from auradefi.api.app import create_app
#     from auradefi.api.deps import Deps
#     app = create_app(Deps(...))      # your stores, your clock
#
#     uvicorn main:app --host 0.0.0.0 --port 8000
#
# Behind a proxy that appends one X-Forwarded-For hop, pass
# `trusted_proxy_hops=1`; it defaults to 0, so no caller can choose the IP
# its own audit row records.
print("\nOK: Plaid's envelope over your ports, and nothing else.")