x402-fastapi-kit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ """
2
+ x402-fastapi-kit — Production-ready starter kit for x402 payment-gated FastAPI services.
3
+
4
+ Deploy an x402-paid API in 10 minutes.
5
+ Bring your data source, set your price per request,
6
+ we handle payments, caching, rate limiting, and agent discovery.
7
+ """
8
+
9
+ # Phase 1 — Core
10
+ # Phase 3 — Discovery, Client, Analytics
11
+ from x402_fastapi_kit.analytics import AnalyticsCollector
12
+ from x402_fastapi_kit.app import PaymentApp
13
+
14
+ # Phase 2 — Access Control & Events
15
+ from x402_fastapi_kit.auth import APIKeyManager, AuthMethod, AuthResult, WalletAllowlist
16
+ from x402_fastapi_kit.cache import RedisCache
17
+ from x402_fastapi_kit.client import DryRunSigner, PaymentPolicy, PaymentSigner, X402Client
18
+ from x402_fastapi_kit.config import X402Settings
19
+ from x402_fastapi_kit.discovery import ServiceManifest, enrich_openapi, register_with_bazaar
20
+ from x402_fastapi_kit.events import EventBus, EventType, PaymentEvent
21
+ from x402_fastapi_kit.mcp import MCPWrapper
22
+ from x402_fastapi_kit.middleware.payment import PaymentGate, require_payment
23
+ from x402_fastapi_kit.middleware.rate_limit import RateLimiter
24
+ from x402_fastapi_kit.middleware.types import RoutePrice
25
+
26
+ __all__ = [
27
+ # Core
28
+ "PaymentApp",
29
+ "PaymentGate",
30
+ "X402Settings",
31
+ "require_payment",
32
+ "RoutePrice",
33
+ "RateLimiter",
34
+ # Auth & Events
35
+ "APIKeyManager",
36
+ "AuthMethod",
37
+ "AuthResult",
38
+ "WalletAllowlist",
39
+ "EventBus",
40
+ "EventType",
41
+ "PaymentEvent",
42
+ # Discovery & MCP
43
+ "ServiceManifest",
44
+ "enrich_openapi",
45
+ "register_with_bazaar",
46
+ "MCPWrapper",
47
+ # Client
48
+ "X402Client",
49
+ "PaymentSigner",
50
+ "DryRunSigner",
51
+ "PaymentPolicy",
52
+ # Infrastructure
53
+ "AnalyticsCollector",
54
+ "RedisCache",
55
+ ]
56
+
57
+ __version__ = "0.3.0"
@@ -0,0 +1,323 @@
1
+ """
2
+ Analytics collector for x402-fastapi-kit.
3
+
4
+ Plugs into the ``EventBus`` to accumulate real-time statistics:
5
+ revenue, request counts, auth breakdowns, top routes, and error rates.
6
+
7
+ Exposes a ``/x402/stats`` JSON endpoint and a ``/x402/dashboard``
8
+ HTML dashboard.
9
+
10
+ Usage::
11
+
12
+ from x402_fastapi_kit.analytics import AnalyticsCollector
13
+
14
+ collector = AnalyticsCollector(event_bus=bus)
15
+
16
+ # Later, register with the app
17
+ collector.mount(app, settings)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from collections import defaultdict
24
+ from dataclasses import dataclass
25
+ from decimal import Decimal
26
+ from threading import Lock
27
+ from typing import Any
28
+
29
+ from fastapi import FastAPI
30
+
31
+ from x402_fastapi_kit.config import X402Settings
32
+ from x402_fastapi_kit.events import EventBus, EventType, PaymentEvent
33
+
34
+
35
+ @dataclass
36
+ class RouteStats:
37
+ """Stats for a single route."""
38
+
39
+ requests: int = 0
40
+ payments: int = 0
41
+ rejections: int = 0
42
+ api_key_hits: int = 0
43
+ allowlist_hits: int = 0
44
+ rate_limited: int = 0
45
+ revenue_cents: int = 0 # in hundredths of a dollar
46
+
47
+ @property
48
+ def revenue_usd(self) -> str:
49
+ return f"${self.revenue_cents / 100:.2f}"
50
+
51
+
52
+ class AnalyticsCollector:
53
+ """
54
+ Real-time analytics collector powered by EventBus.
55
+
56
+ Subscribes to all payment lifecycle events and maintains
57
+ in-memory counters. Thread-safe for concurrent access.
58
+
59
+ Args:
60
+ event_bus: The EventBus instance to subscribe to.
61
+ """
62
+
63
+ def __init__(self, event_bus: EventBus) -> None:
64
+ self._bus = event_bus
65
+ self._lock = Lock()
66
+
67
+ # Global counters
68
+ self._total_requests = 0
69
+ self._total_payments = 0
70
+ self._total_rejections = 0
71
+ self._total_rate_limited = 0
72
+ self._total_facilitator_errors = 0
73
+ self._total_revenue_cents = 0
74
+
75
+ # Per-route stats
76
+ self._route_stats: dict[str, RouteStats] = defaultdict(RouteStats)
77
+
78
+ # Per-auth-method counts
79
+ self._auth_counts: dict[str, int] = defaultdict(int)
80
+
81
+ # Recent events (ring buffer, last 200)
82
+ self._recent: list[dict[str, Any]] = []
83
+ self._max_recent = 200
84
+
85
+ # Hourly revenue buckets (hour_key → cents)
86
+ self._hourly_revenue: dict[str, int] = defaultdict(int)
87
+
88
+ # Start time
89
+ self._started_at = time.time()
90
+
91
+ # Subscribe to all events
92
+ self._bus.on_all(self._handle_event)
93
+
94
+ def _hour_key(self, ts: float | None = None) -> str:
95
+ t = time.gmtime(ts or time.time())
96
+ return f"{t.tm_year}-{t.tm_mon:02d}-{t.tm_mday:02d}T{t.tm_hour:02d}"
97
+
98
+ def _price_to_cents(self, price: str | None) -> int:
99
+ if not price:
100
+ return 0
101
+ try:
102
+ return int(Decimal(price.lstrip("$")) * 100)
103
+ except Exception:
104
+ return 0
105
+
106
+ def _handle_event(self, event: PaymentEvent) -> None:
107
+ """Process a single event (called by EventBus)."""
108
+ with self._lock:
109
+ route = event.route
110
+ rs = self._route_stats[route]
111
+ self._total_requests += 1
112
+ rs.requests += 1
113
+
114
+ evt_type = event.event_type
115
+
116
+ if evt_type == EventType.PAYMENT_VERIFIED:
117
+ self._total_payments += 1
118
+ rs.payments += 1
119
+ cents = self._price_to_cents(event.price)
120
+ self._total_revenue_cents += cents
121
+ rs.revenue_cents += cents
122
+ self._hourly_revenue[self._hour_key(event.timestamp)] += cents
123
+ self._auth_counts["x402"] += 1
124
+
125
+ elif evt_type == EventType.PAYMENT_REJECTED:
126
+ self._total_rejections += 1
127
+ rs.rejections += 1
128
+
129
+ elif evt_type == EventType.REQUEST_GATED:
130
+ pass # counted in total_requests already
131
+
132
+ elif evt_type == EventType.AUTH_API_KEY:
133
+ rs.api_key_hits += 1
134
+ self._auth_counts["api_key"] += 1
135
+
136
+ elif evt_type == EventType.AUTH_ALLOWLIST:
137
+ rs.allowlist_hits += 1
138
+ self._auth_counts["allowlist"] += 1
139
+
140
+ elif evt_type == EventType.RATE_LIMITED:
141
+ self._total_rate_limited += 1
142
+ rs.rate_limited += 1
143
+
144
+ elif evt_type == EventType.FACILITATOR_ERROR:
145
+ self._total_facilitator_errors += 1
146
+
147
+ # Add to recent events
148
+ self._recent.append({
149
+ "type": evt_type.value
150
+ if isinstance(evt_type, EventType) else str(evt_type),
151
+ "route": route,
152
+ "client": event.client_id,
153
+ "price": event.price,
154
+ "tx": event.tx_hash,
155
+ "time": event.timestamp,
156
+ })
157
+ if len(self._recent) > self._max_recent:
158
+ self._recent = self._recent[-self._max_recent:]
159
+
160
+ def snapshot(self) -> dict[str, Any]:
161
+ """Return a point-in-time analytics snapshot."""
162
+ with self._lock:
163
+ uptime = time.time() - self._started_at
164
+
165
+ routes = {}
166
+ for route, rs in sorted(self._route_stats.items()):
167
+ routes[route] = {
168
+ "requests": rs.requests,
169
+ "payments": rs.payments,
170
+ "rejections": rs.rejections,
171
+ "api_key_hits": rs.api_key_hits,
172
+ "allowlist_hits": rs.allowlist_hits,
173
+ "rate_limited": rs.rate_limited,
174
+ "revenue": rs.revenue_usd,
175
+ }
176
+
177
+ return {
178
+ "uptime_seconds": round(uptime, 1),
179
+ "totals": {
180
+ "requests": self._total_requests,
181
+ "payments": self._total_payments,
182
+ "rejections": self._total_rejections,
183
+ "rate_limited": self._total_rate_limited,
184
+ "facilitator_errors": self._total_facilitator_errors,
185
+ "revenue": f"${self._total_revenue_cents / 100:.2f}",
186
+ },
187
+ "auth_breakdown": dict(self._auth_counts),
188
+ "routes": routes,
189
+ "hourly_revenue": dict(self._hourly_revenue),
190
+ "recent_events": list(self._recent[-50:]),
191
+ }
192
+
193
+ def mount(self, app: FastAPI, settings: X402Settings) -> None:
194
+ """Register /x402/stats and /x402/dashboard endpoints."""
195
+ collector = self
196
+
197
+ @app.get("/x402/stats", tags=["x402"])
198
+ async def x402_stats() -> dict:
199
+ """Real-time analytics snapshot (JSON)."""
200
+ return collector.snapshot()
201
+
202
+ @app.get(
203
+ "/x402/dashboard",
204
+ tags=["x402"],
205
+ response_class=_HTMLResponse,
206
+ )
207
+ async def x402_dashboard() -> _HTMLResponse:
208
+ """Live analytics dashboard (HTML)."""
209
+ return _HTMLResponse(content=_DASHBOARD_HTML)
210
+
211
+
212
+ # ── Minimal HTML response class ──────────────────────────────────────────────
213
+
214
+ class _HTMLResponse:
215
+ """Minimal HTML response to avoid import of starlette in module scope."""
216
+ media_type = "text/html"
217
+
218
+ def __init__(self, content: str, status_code: int = 200) -> None:
219
+ self.body = content.encode()
220
+ self.status_code = status_code
221
+
222
+
223
+ # We use starlette's actual response at runtime
224
+ from starlette.responses import HTMLResponse as _HTMLResponse # noqa: E402, F811
225
+
226
+ # ── Dashboard HTML ────────────────────────────────────────────────────────────
227
+
228
+ _DASHBOARD_HTML = """<!DOCTYPE html>
229
+ <html lang="en">
230
+ <head>
231
+ <meta charset="utf-8">
232
+ <title>x402 Dashboard</title>
233
+ <meta name="viewport" content="width=device-width,initial-scale=1">
234
+ <style>
235
+ *{box-sizing:border-box;margin:0;padding:0}
236
+ body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#e0e0e0;padding:2rem}
237
+ h1{font-size:1.5rem;margin-bottom:1.5rem;color:#fff}
238
+ h1 span{color:#4ade80;font-weight:400}
239
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
240
+ .card{background:#1a1a2e;border-radius:12px;padding:1.25rem;border:1px solid #2a2a3e}
241
+ .card .label{font-size:.75rem;color:#888;text-transform:uppercase;letter-spacing:.05em}
242
+ .card .value{font-size:1.75rem;font-weight:700;margin-top:.25rem;color:#fff}
243
+ .card .value.green{color:#4ade80}
244
+ .card .value.red{color:#f87171}
245
+ .card .value.blue{color:#60a5fa}
246
+ .card .value.yellow{color:#fbbf24}
247
+ table{width:100%;border-collapse:collapse;background:#1a1a2e;border-radius:12px;overflow:hidden;border:1px solid #2a2a3e}
248
+ th,td{padding:.75rem 1rem;text-align:left;border-bottom:1px solid #2a2a3e}
249
+ th{background:#12122a;color:#888;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
250
+ td{font-size:.875rem}
251
+ .tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.75rem;font-weight:600}
252
+ .tag-green{background:#064e3b;color:#4ade80}
253
+ .tag-red{background:#450a0a;color:#f87171}
254
+ .tag-blue{background:#172554;color:#60a5fa}
255
+ .refresh{color:#888;font-size:.75rem;margin-top:1rem;text-align:center}
256
+ h2{font-size:1.1rem;margin:1.5rem 0 .75rem;color:#ccc}
257
+ </style>
258
+ </head>
259
+ <body>
260
+ <h1>x402 <span>Dashboard</span></h1>
261
+ <div id="app"><p style="color:#888">Loading…</p></div>
262
+ <p class="refresh">Auto-refreshes every 5s</p>
263
+ <script>
264
+ async function refresh(){
265
+ try{
266
+ const r=await fetch('/x402/stats');
267
+ const d=await r.json();
268
+ const t=d.totals;
269
+ const routes=d.routes||{};
270
+ const auth=d.auth_breakdown||{};
271
+ let html=`
272
+ <div class="grid">
273
+ <div class="card"><div class="label">Revenue</div><div class="value green">${t.revenue}</div></div>
274
+ <div class="card"><div class="label">Payments</div><div class="value blue">${t.payments}</div></div>
275
+ <div class="card"><div class="label">Total Requests</div><div class="value">${t.requests}</div></div>
276
+ <div class="card"><div class="label">Rejections</div><div class="value red">${t.rejections}</div></div>
277
+ <div class="card"><div class="label">Rate Limited</div><div class="value yellow">${t.rate_limited}</div></div>
278
+ <div class="card"><div class="label">Uptime</div><div class="value">${Math.round(d.uptime_seconds)}s</div></div>
279
+ </div>
280
+ <h2>Auth Breakdown</h2>
281
+ <div class="grid">
282
+ <div class="card"><div class="label">x402 Payments</div><div class="value green">${auth.x402||0}</div></div>
283
+ <div class="card"><div class="label">API Key</div><div class="value blue">${auth.api_key||0}</div></div>
284
+ <div class="card"><div class="label">Allowlist</div><div class="value">${auth.allowlist||0}</div></div>
285
+ </div>
286
+ <h2>Routes</h2>
287
+ <table>
288
+ <thead><tr><th>Route</th><th>Requests</th><th>Payments</th><th>Revenue</th><th>API Key</th><th>Rate Ltd</th></tr></thead>
289
+ <tbody>`;
290
+ for(const[route,s]of Object.entries(routes)){
291
+ html+=`<tr>
292
+ <td><code>${route}</code></td>
293
+ <td>${s.requests}</td>
294
+ <td><span class="tag tag-green">${s.payments}</span></td>
295
+ <td>${s.revenue}</td>
296
+ <td><span class="tag tag-blue">${s.api_key_hits}</span></td>
297
+ <td>${s.rate_limited?'<span class="tag tag-red">'+s.rate_limited+'</span>':'0'}</td>
298
+ </tr>`;
299
+ }
300
+ html+=`</tbody></table>`;
301
+ const events=d.recent_events||[];
302
+ if(events.length){
303
+ html+=`<h2>Recent Events (last ${events.length})</h2><table>
304
+ <thead><tr><th>Type</th><th>Route</th><th>Client</th><th>Price</th></tr></thead><tbody>`;
305
+ for(const e of events.slice(-20).reverse()){
306
+ const cls=e.type.includes('verified')?'tag-green':e.type.includes('reject')?'tag-red':'tag-blue';
307
+ html+=`<tr>
308
+ <td><span class="tag ${cls}">${e.type}</span></td>
309
+ <td><code>${e.route}</code></td>
310
+ <td>${(e.client||'—').substring(0,12)}</td>
311
+ <td>${e.price||'—'}</td>
312
+ </tr>`;
313
+ }
314
+ html+=`</tbody></table>`;
315
+ }
316
+ document.getElementById('app').innerHTML=html;
317
+ }catch(e){document.getElementById('app').innerHTML='<p style="color:#f87171">Error loading stats</p>'}
318
+ }
319
+ refresh();
320
+ setInterval(refresh,5000);
321
+ </script>
322
+ </body>
323
+ </html>"""
@@ -0,0 +1,135 @@
1
+ """
2
+ PaymentApp — batteries-included FastAPI subclass for x402 services.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from fastapi import FastAPI
11
+
12
+ from x402_fastapi_kit.analytics import AnalyticsCollector
13
+ from x402_fastapi_kit.auth import APIKeyManager, WalletAllowlist
14
+ from x402_fastapi_kit.config import X402Settings
15
+ from x402_fastapi_kit.discovery import (
16
+ ServiceManifest,
17
+ enrich_openapi,
18
+ register_well_known,
19
+ )
20
+ from x402_fastapi_kit.events import EventBus
21
+ from x402_fastapi_kit.middleware.payment import PaymentGate, get_registry
22
+ from x402_fastapi_kit.middleware.rate_limit import RateLimiter
23
+
24
+ logger = logging.getLogger("x402_fastapi_kit")
25
+
26
+
27
+ class PaymentApp(FastAPI):
28
+ """
29
+ FastAPI application pre-configured with the full x402-fastapi-kit stack.
30
+
31
+ Phases 1–3:
32
+ - Payment middleware with 4-step pipeline
33
+ - API key auth, wallet allowlist, rate limiting, event hooks
34
+ - Service discovery (.well-known/x402), OpenAPI enrichment, analytics
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ settings: X402Settings | None = None,
40
+ *,
41
+ event_bus: EventBus | None = None,
42
+ api_key_manager: APIKeyManager | None = None,
43
+ wallet_allowlist: WalletAllowlist | None = None,
44
+ rate_limiter: RateLimiter | None = None,
45
+ manifest: ServiceManifest | None = None,
46
+ analytics: bool = True,
47
+ **kwargs: Any,
48
+ ) -> None:
49
+ self._x402_settings = settings
50
+ self._event_bus = event_bus or EventBus()
51
+ self._api_key_manager = api_key_manager
52
+ self._wallet_allowlist = wallet_allowlist
53
+ self._rate_limiter = rate_limiter
54
+ self._manifest = manifest
55
+ self._analytics_enabled = analytics
56
+ self._analytics_collector: AnalyticsCollector | None = None
57
+
58
+ kwargs.setdefault("title", "x402-fastapi-kit Service")
59
+ super().__init__(**kwargs)
60
+
61
+ # Introspection endpoints (Phase 1)
62
+ self._register_introspection()
63
+
64
+ # Discovery (Phase 3)
65
+ if self._manifest is not None:
66
+ register_well_known(self, self.x402_settings, self._manifest)
67
+
68
+ # Analytics (Phase 3)
69
+ if self._analytics_enabled:
70
+ self._analytics_collector = AnalyticsCollector(self._event_bus)
71
+ self._analytics_collector.mount(self, self.x402_settings)
72
+
73
+ # Payment middleware (must be added last — Starlette wraps in reverse)
74
+ self.add_middleware(
75
+ PaymentGate,
76
+ settings=self.x402_settings,
77
+ owner_app=self,
78
+ event_bus=self._event_bus,
79
+ api_key_manager=self._api_key_manager,
80
+ wallet_allowlist=self._wallet_allowlist,
81
+ rate_limiter=self._rate_limiter,
82
+ )
83
+
84
+ @property
85
+ def x402_settings(self) -> X402Settings:
86
+ if self._x402_settings is None:
87
+ self._x402_settings = X402Settings()
88
+ return self._x402_settings
89
+
90
+ @property
91
+ def event_bus(self) -> EventBus:
92
+ return self._event_bus
93
+
94
+ @property
95
+ def analytics(self) -> AnalyticsCollector | None:
96
+ return self._analytics_collector
97
+
98
+ def enrich_openapi_schema(self) -> None:
99
+ """Inject x402 metadata into the OpenAPI schema."""
100
+ enrich_openapi(self, self.x402_settings)
101
+
102
+ def _register_introspection(self) -> None:
103
+ settings_ref = self
104
+
105
+ @self.get("/x402/health", tags=["x402"], include_in_schema=True)
106
+ async def x402_health() -> dict:
107
+ s = settings_ref.x402_settings
108
+ registry = get_registry()
109
+ return {
110
+ "status": "ok",
111
+ "version": "0.3.0",
112
+ "network": s.network,
113
+ "is_testnet": s.is_testnet,
114
+ "facilitator": s.facilitator_url,
115
+ "gated_routes": len(registry),
116
+ "rate_limit_enabled": s.rate_limit_enabled,
117
+ "api_keys_enabled": s.api_keys_enabled,
118
+ "allowlist_enabled": s.allowlist_enabled,
119
+ "analytics_enabled": settings_ref._analytics_enabled,
120
+ "manifest_enabled": settings_ref._manifest is not None,
121
+ }
122
+
123
+ @self.get("/x402/routes", tags=["x402"], include_in_schema=True)
124
+ async def x402_routes() -> dict:
125
+ s = settings_ref.x402_settings
126
+ registry = get_registry()
127
+ routes = {}
128
+ for key, rp in registry.all().items():
129
+ routes[key] = {
130
+ "price": rp.price,
131
+ "description": rp.description or s.default_description,
132
+ "network": rp.network or s.network,
133
+ "scheme": rp.scheme or s.scheme,
134
+ }
135
+ return {"routes": routes}