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.
- x402_fastapi_kit/__init__.py +57 -0
- x402_fastapi_kit/analytics/__init__.py +323 -0
- x402_fastapi_kit/app.py +135 -0
- x402_fastapi_kit/auth/__init__.py +221 -0
- x402_fastapi_kit/cache.py +247 -0
- x402_fastapi_kit/cli/__init__.py +1 -0
- x402_fastapi_kit/cli/main.py +169 -0
- x402_fastapi_kit/client/__init__.py +353 -0
- x402_fastapi_kit/config/__init__.py +191 -0
- x402_fastapi_kit/config/settings.py +4 -0
- x402_fastapi_kit/discovery/__init__.py +208 -0
- x402_fastapi_kit/events.py +180 -0
- x402_fastapi_kit/mcp/__init__.py +235 -0
- x402_fastapi_kit/middleware/__init__.py +22 -0
- x402_fastapi_kit/middleware/payment.py +454 -0
- x402_fastapi_kit/middleware/rate_limit.py +205 -0
- x402_fastapi_kit/middleware/redis_backend.py +133 -0
- x402_fastapi_kit/middleware/types.py +65 -0
- x402_fastapi_kit/py.typed +0 -0
- x402_fastapi_kit/utils/__init__.py +52 -0
- x402_fastapi_kit-0.3.0.dist-info/METADATA +425 -0
- x402_fastapi_kit-0.3.0.dist-info/RECORD +25 -0
- x402_fastapi_kit-0.3.0.dist-info/WHEEL +4 -0
- x402_fastapi_kit-0.3.0.dist-info/entry_points.txt +2 -0
- x402_fastapi_kit-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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>"""
|
x402_fastapi_kit/app.py
ADDED
|
@@ -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}
|