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.
@@ -0,0 +1,4 @@
1
+ """ANIP FastAPI bindings — mount an ANIPService as HTTP routes."""
2
+ from .routes import mount_anip
3
+
4
+ __all__ = ["mount_anip"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ anip_fastapi