anip-fastapi 0.11.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.
anip_fastapi/__init__.py
ADDED
anip_fastapi/routes.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Mount ANIP routes onto a FastAPI application."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Request, Response
|
|
9
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
10
|
+
|
|
11
|
+
from anip_service import ANIPService, ANIPError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def mount_anip(
|
|
15
|
+
app: FastAPI,
|
|
16
|
+
service: ANIPService,
|
|
17
|
+
prefix: str = "",
|
|
18
|
+
*,
|
|
19
|
+
health_endpoint: bool = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Mount all ANIP protocol routes onto a FastAPI app.
|
|
22
|
+
|
|
23
|
+
Routes:
|
|
24
|
+
GET {prefix}/.well-known/anip → discovery
|
|
25
|
+
GET {prefix}/.well-known/jwks.json → JWKS
|
|
26
|
+
GET {prefix}/anip/manifest → full manifest (signed)
|
|
27
|
+
POST {prefix}/anip/tokens → issue token
|
|
28
|
+
POST {prefix}/anip/permissions → discover permissions
|
|
29
|
+
POST {prefix}/anip/invoke/{capability} → invoke capability
|
|
30
|
+
POST {prefix}/anip/audit → query audit log
|
|
31
|
+
GET {prefix}/anip/checkpoints → list checkpoints
|
|
32
|
+
GET {prefix}/anip/checkpoints/{id} → get checkpoint
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# --- Lifecycle: wire start/stop into FastAPI events ---
|
|
36
|
+
app.router.on_startup.append(service.start)
|
|
37
|
+
app.router.on_shutdown.append(service.shutdown) # async flush first
|
|
38
|
+
app.router.on_shutdown.append(service.stop) # sync timer cleanup
|
|
39
|
+
|
|
40
|
+
# --- Discovery & Identity ---
|
|
41
|
+
|
|
42
|
+
@app.get(f"{prefix}/.well-known/anip")
|
|
43
|
+
async def discovery(request: Request):
|
|
44
|
+
base_url = str(request.base_url).rstrip("/")
|
|
45
|
+
return service.get_discovery(base_url=base_url)
|
|
46
|
+
|
|
47
|
+
@app.get(f"{prefix}/.well-known/jwks.json")
|
|
48
|
+
async def jwks():
|
|
49
|
+
return service.get_jwks()
|
|
50
|
+
|
|
51
|
+
@app.get(f"{prefix}/anip/manifest")
|
|
52
|
+
async def manifest():
|
|
53
|
+
body_bytes, signature = service.get_signed_manifest()
|
|
54
|
+
return Response(
|
|
55
|
+
content=body_bytes,
|
|
56
|
+
media_type="application/json",
|
|
57
|
+
headers={"X-ANIP-Signature": signature},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# --- Tokens ---
|
|
61
|
+
|
|
62
|
+
@app.post(f"{prefix}/anip/tokens")
|
|
63
|
+
async def issue_token(request: Request):
|
|
64
|
+
principal = await _extract_principal(request, service)
|
|
65
|
+
if principal is None:
|
|
66
|
+
return _auth_failure_token_endpoint()
|
|
67
|
+
|
|
68
|
+
body = await request.json()
|
|
69
|
+
try:
|
|
70
|
+
result = await service.issue_token(principal, body)
|
|
71
|
+
return result
|
|
72
|
+
except ANIPError as e:
|
|
73
|
+
return _error_response(e)
|
|
74
|
+
|
|
75
|
+
# --- Permissions ---
|
|
76
|
+
|
|
77
|
+
@app.post(f"{prefix}/anip/permissions")
|
|
78
|
+
async def permissions(request: Request):
|
|
79
|
+
result = await _resolve_token(request, service)
|
|
80
|
+
if result is None:
|
|
81
|
+
return _auth_failure_jwt_endpoint()
|
|
82
|
+
if isinstance(result, ANIPError):
|
|
83
|
+
return _error_response(result)
|
|
84
|
+
token = result
|
|
85
|
+
return service.discover_permissions(token)
|
|
86
|
+
|
|
87
|
+
# --- Invoke ---
|
|
88
|
+
|
|
89
|
+
@app.post(f"{prefix}/anip/invoke/{{capability}}")
|
|
90
|
+
async def invoke(capability: str, request: Request):
|
|
91
|
+
result = await _resolve_token(request, service)
|
|
92
|
+
if result is None:
|
|
93
|
+
return _auth_failure_jwt_endpoint()
|
|
94
|
+
if isinstance(result, ANIPError):
|
|
95
|
+
return _error_response(result)
|
|
96
|
+
token = result
|
|
97
|
+
|
|
98
|
+
body = await request.json()
|
|
99
|
+
params = body.get("parameters", body)
|
|
100
|
+
client_reference_id = body.get("client_reference_id")
|
|
101
|
+
stream = body.get("stream", False)
|
|
102
|
+
|
|
103
|
+
if not stream:
|
|
104
|
+
# Unary mode — existing behavior
|
|
105
|
+
result = await service.invoke(
|
|
106
|
+
capability, token, params,
|
|
107
|
+
client_reference_id=client_reference_id,
|
|
108
|
+
)
|
|
109
|
+
if not result.get("success"):
|
|
110
|
+
status = _failure_status(result.get("failure", {}).get("type"))
|
|
111
|
+
return JSONResponse(result, status_code=status)
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
# Streaming mode — pre-validate streaming support (return JSON 400, not SSE)
|
|
115
|
+
decl = service.get_capability_declaration(capability)
|
|
116
|
+
if decl is not None:
|
|
117
|
+
modes = [m.value if hasattr(m, 'value') else m for m in (decl.response_modes or ["unary"])] # pyright: ignore[reportAttributeAccessIssue]
|
|
118
|
+
if "streaming" not in modes:
|
|
119
|
+
result = await service.invoke(
|
|
120
|
+
capability, token, params,
|
|
121
|
+
client_reference_id=client_reference_id,
|
|
122
|
+
stream=True,
|
|
123
|
+
)
|
|
124
|
+
status = _failure_status(result.get("failure", {}).get("type"))
|
|
125
|
+
return JSONResponse(result, status_code=status)
|
|
126
|
+
|
|
127
|
+
# True streaming: asyncio.Queue bridges sink -> SSE generator
|
|
128
|
+
queue: asyncio.Queue[dict] = asyncio.Queue()
|
|
129
|
+
|
|
130
|
+
async def progress_sink(event: dict) -> None:
|
|
131
|
+
await queue.put({"type": "progress", **event})
|
|
132
|
+
|
|
133
|
+
async def run_invoke():
|
|
134
|
+
try:
|
|
135
|
+
result = await service.invoke(
|
|
136
|
+
capability, token, params,
|
|
137
|
+
client_reference_id=client_reference_id,
|
|
138
|
+
stream=True,
|
|
139
|
+
_progress_sink=progress_sink,
|
|
140
|
+
)
|
|
141
|
+
await queue.put({"type": "terminal", "result": result})
|
|
142
|
+
except Exception as e:
|
|
143
|
+
await queue.put({"type": "error", "detail": "Internal error"})
|
|
144
|
+
|
|
145
|
+
async def sse_generator():
|
|
146
|
+
task = asyncio.create_task(run_invoke())
|
|
147
|
+
try:
|
|
148
|
+
while True:
|
|
149
|
+
event = await queue.get()
|
|
150
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
151
|
+
|
|
152
|
+
if event["type"] == "progress":
|
|
153
|
+
event_data = {
|
|
154
|
+
"invocation_id": event["invocation_id"],
|
|
155
|
+
"client_reference_id": event.get("client_reference_id"),
|
|
156
|
+
"timestamp": ts,
|
|
157
|
+
"payload": event["payload"],
|
|
158
|
+
}
|
|
159
|
+
yield f"event: progress\ndata: {json.dumps(event_data)}\n\n"
|
|
160
|
+
|
|
161
|
+
elif event["type"] == "terminal":
|
|
162
|
+
result = event["result"]
|
|
163
|
+
if result.get("success"):
|
|
164
|
+
event_data = {
|
|
165
|
+
"invocation_id": result["invocation_id"],
|
|
166
|
+
"client_reference_id": result.get("client_reference_id"),
|
|
167
|
+
"timestamp": ts,
|
|
168
|
+
"success": True,
|
|
169
|
+
"result": result.get("result"),
|
|
170
|
+
"cost_actual": result.get("cost_actual"),
|
|
171
|
+
}
|
|
172
|
+
if "stream_summary" in result:
|
|
173
|
+
event_data["stream_summary"] = result["stream_summary"]
|
|
174
|
+
yield f"event: completed\ndata: {json.dumps(event_data)}\n\n"
|
|
175
|
+
else:
|
|
176
|
+
event_data = {
|
|
177
|
+
"invocation_id": result.get("invocation_id"),
|
|
178
|
+
"client_reference_id": result.get("client_reference_id"),
|
|
179
|
+
"timestamp": ts,
|
|
180
|
+
"success": False,
|
|
181
|
+
"failure": result.get("failure"),
|
|
182
|
+
}
|
|
183
|
+
if "stream_summary" in result:
|
|
184
|
+
event_data["stream_summary"] = result["stream_summary"]
|
|
185
|
+
yield f"event: failed\ndata: {json.dumps(event_data)}\n\n"
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
elif event["type"] == "error":
|
|
189
|
+
event_data = {
|
|
190
|
+
"timestamp": ts,
|
|
191
|
+
"success": False,
|
|
192
|
+
"failure": {"type": "internal_error", "detail": event["detail"]},
|
|
193
|
+
}
|
|
194
|
+
yield f"event: failed\ndata: {json.dumps(event_data)}\n\n"
|
|
195
|
+
break
|
|
196
|
+
finally:
|
|
197
|
+
await task
|
|
198
|
+
|
|
199
|
+
return StreamingResponse(
|
|
200
|
+
sse_generator(),
|
|
201
|
+
media_type="text/event-stream",
|
|
202
|
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# --- Audit ---
|
|
206
|
+
|
|
207
|
+
@app.post(f"{prefix}/anip/audit")
|
|
208
|
+
async def audit(request: Request):
|
|
209
|
+
result = await _resolve_token(request, service)
|
|
210
|
+
if result is None:
|
|
211
|
+
return _auth_failure_jwt_endpoint()
|
|
212
|
+
if isinstance(result, ANIPError):
|
|
213
|
+
return _error_response(result)
|
|
214
|
+
token = result
|
|
215
|
+
|
|
216
|
+
filters = {
|
|
217
|
+
"capability": request.query_params.get("capability"),
|
|
218
|
+
"since": request.query_params.get("since"),
|
|
219
|
+
"invocation_id": request.query_params.get("invocation_id"),
|
|
220
|
+
"client_reference_id": request.query_params.get("client_reference_id"),
|
|
221
|
+
"event_class": request.query_params.get("event_class"),
|
|
222
|
+
"limit": int(request.query_params.get("limit", "50")),
|
|
223
|
+
}
|
|
224
|
+
return await service.query_audit(token, filters)
|
|
225
|
+
|
|
226
|
+
# --- Checkpoints ---
|
|
227
|
+
|
|
228
|
+
@app.get(f"{prefix}/anip/checkpoints")
|
|
229
|
+
async def list_checkpoints(request: Request):
|
|
230
|
+
limit = int(request.query_params.get("limit", "10"))
|
|
231
|
+
return await service.get_checkpoints(limit)
|
|
232
|
+
|
|
233
|
+
@app.get(f"{prefix}/anip/checkpoints/{{checkpoint_id}}")
|
|
234
|
+
async def get_checkpoint(checkpoint_id: str, request: Request):
|
|
235
|
+
options = {
|
|
236
|
+
"include_proof": request.query_params.get("include_proof") == "true",
|
|
237
|
+
"leaf_index": request.query_params.get("leaf_index"),
|
|
238
|
+
"consistency_from": request.query_params.get("consistency_from"),
|
|
239
|
+
}
|
|
240
|
+
result = await service.get_checkpoint(checkpoint_id, options)
|
|
241
|
+
if result is None:
|
|
242
|
+
return JSONResponse(
|
|
243
|
+
{
|
|
244
|
+
"success": False,
|
|
245
|
+
"failure": {
|
|
246
|
+
"type": "not_found",
|
|
247
|
+
"detail": f"Checkpoint {checkpoint_id} not found",
|
|
248
|
+
"resolution": {
|
|
249
|
+
"action": "list_checkpoints",
|
|
250
|
+
"requires": "GET /anip/checkpoints to find valid checkpoint IDs",
|
|
251
|
+
"grantable_by": None,
|
|
252
|
+
"estimated_availability": None,
|
|
253
|
+
},
|
|
254
|
+
"retry": False,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
status_code=404,
|
|
258
|
+
)
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
# --- Health ---
|
|
262
|
+
if health_endpoint:
|
|
263
|
+
@app.get("/-/health")
|
|
264
|
+
def health():
|
|
265
|
+
return service.get_health()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def _extract_principal(request: Request, service: ANIPService) -> str | None:
|
|
269
|
+
"""Extract authenticated principal from the request.
|
|
270
|
+
|
|
271
|
+
Uses service.authenticate_bearer() which tries bootstrap auth (API keys,
|
|
272
|
+
external auth) first, then ANIP JWT verification. This is critical for
|
|
273
|
+
first-token issuance before any ANIP tokens exist.
|
|
274
|
+
"""
|
|
275
|
+
auth = request.headers.get("authorization", "")
|
|
276
|
+
if not auth.startswith("Bearer "):
|
|
277
|
+
return None
|
|
278
|
+
bearer_value = auth[7:].strip()
|
|
279
|
+
return await service.authenticate_bearer(bearer_value)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def _resolve_token(request: Request, service: ANIPService):
|
|
283
|
+
"""Resolve a bearer token from the Authorization header.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
DelegationToken if valid, ANIPError if invalid/expired, None if no header.
|
|
287
|
+
"""
|
|
288
|
+
auth = request.headers.get("authorization", "")
|
|
289
|
+
if not auth.startswith("Bearer "):
|
|
290
|
+
return None
|
|
291
|
+
jwt_str = auth[7:].strip()
|
|
292
|
+
try:
|
|
293
|
+
return await service.resolve_bearer_token(jwt_str)
|
|
294
|
+
except ANIPError as e:
|
|
295
|
+
return e
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _failure_status(failure_type: str | None) -> int:
|
|
299
|
+
"""Map ANIP failure types to HTTP status codes."""
|
|
300
|
+
mapping = {
|
|
301
|
+
"invalid_token": 401,
|
|
302
|
+
"token_expired": 401,
|
|
303
|
+
"scope_insufficient": 403,
|
|
304
|
+
"insufficient_authority": 403,
|
|
305
|
+
"purpose_mismatch": 403,
|
|
306
|
+
"unknown_capability": 404,
|
|
307
|
+
"not_found": 404,
|
|
308
|
+
"unavailable": 409,
|
|
309
|
+
"concurrent_lock": 409,
|
|
310
|
+
"internal_error": 500,
|
|
311
|
+
}
|
|
312
|
+
return mapping.get(failure_type or "", 400)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
_DEFAULT_RESOLUTIONS: dict[str, dict] = {
|
|
316
|
+
"invalid_token": {
|
|
317
|
+
"action": "obtain_delegation_token",
|
|
318
|
+
"requires": "Valid JWT from POST /anip/tokens",
|
|
319
|
+
"grantable_by": None,
|
|
320
|
+
"estimated_availability": None,
|
|
321
|
+
},
|
|
322
|
+
"scope_insufficient": {
|
|
323
|
+
"action": "request_broader_scope",
|
|
324
|
+
"requires": "Token with required scope",
|
|
325
|
+
"grantable_by": None,
|
|
326
|
+
"estimated_availability": None,
|
|
327
|
+
},
|
|
328
|
+
"unknown_capability": {
|
|
329
|
+
"action": "check_manifest",
|
|
330
|
+
"requires": "Valid capability name from GET /anip/manifest",
|
|
331
|
+
"grantable_by": None,
|
|
332
|
+
"estimated_availability": None,
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _error_response(error: ANIPError) -> JSONResponse:
|
|
338
|
+
"""Map an ANIPError to a JSONResponse."""
|
|
339
|
+
status = _failure_status(error.error_type)
|
|
340
|
+
resolution = error.resolution or _DEFAULT_RESOLUTIONS.get(
|
|
341
|
+
error.error_type,
|
|
342
|
+
{"action": "contact_service_owner", "requires": None, "grantable_by": None, "estimated_availability": None},
|
|
343
|
+
)
|
|
344
|
+
return JSONResponse(
|
|
345
|
+
{
|
|
346
|
+
"success": False,
|
|
347
|
+
"failure": {
|
|
348
|
+
"type": error.error_type,
|
|
349
|
+
"detail": error.detail,
|
|
350
|
+
"resolution": resolution,
|
|
351
|
+
"retry": error.retry,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
status_code=status,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _auth_failure_token_endpoint() -> JSONResponse:
|
|
359
|
+
"""Structured auth failure for POST /anip/tokens (API key required)."""
|
|
360
|
+
return JSONResponse(
|
|
361
|
+
{
|
|
362
|
+
"success": False,
|
|
363
|
+
"failure": {
|
|
364
|
+
"type": "authentication_required",
|
|
365
|
+
"detail": "A valid API key is required to issue delegation tokens",
|
|
366
|
+
"resolution": {
|
|
367
|
+
"action": "provide_api_key",
|
|
368
|
+
"requires": "API key in Authorization header",
|
|
369
|
+
"grantable_by": None,
|
|
370
|
+
"estimated_availability": None,
|
|
371
|
+
},
|
|
372
|
+
"retry": True,
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
status_code=401,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _auth_failure_jwt_endpoint() -> JSONResponse:
|
|
380
|
+
"""Structured auth failure for JWT-authenticated endpoints (no header)."""
|
|
381
|
+
return JSONResponse(
|
|
382
|
+
{
|
|
383
|
+
"success": False,
|
|
384
|
+
"failure": {
|
|
385
|
+
"type": "authentication_required",
|
|
386
|
+
"detail": "A valid delegation token (JWT) is required in the Authorization header",
|
|
387
|
+
"resolution": {
|
|
388
|
+
"action": "obtain_delegation_token",
|
|
389
|
+
"requires": "Bearer token from POST /anip/tokens",
|
|
390
|
+
"grantable_by": None,
|
|
391
|
+
"estimated_availability": None,
|
|
392
|
+
},
|
|
393
|
+
"retry": True,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
status_code=401,
|
|
397
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anip-fastapi
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: ANIP FastAPI bindings — mount an ANIPService as HTTP routes
|
|
5
|
+
Author-email: ANIP Protocol <team@anip.dev>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/anip-protocol/anip
|
|
8
|
+
Keywords: anip,agent,protocol
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: anip-service==0.11.0
|
|
16
|
+
Requires-Dist: fastapi>=0.115.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
|
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
anip_fastapi/__init__.py,sha256=BoPBvhOA8IZUNvwmIAm3Hh7dVa3vrnaSqzdtEjKwV4k,126
|
|
2
|
+
anip_fastapi/routes.py,sha256=3uugJ7_7yDWWesv7gN8mmybZG8nnAlrij17YlC0xK7E,15003
|
|
3
|
+
anip_fastapi-0.11.0.dist-info/METADATA,sha256=EnTmz6sXnNvyBwy-hdlEFGh4qRUgGXHMwtFyXWpKS1s,742
|
|
4
|
+
anip_fastapi-0.11.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
anip_fastapi-0.11.0.dist-info/top_level.txt,sha256=9ADxLrckQnrnieKZ3hzFT6dcFY0Ud-MDM73OcivsiA4,13
|
|
6
|
+
anip_fastapi-0.11.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
anip_fastapi
|