anip-fastapi 0.11.0__tar.gz
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-0.11.0/PKG-INFO +19 -0
- anip_fastapi-0.11.0/README.md +15 -0
- anip_fastapi-0.11.0/pyproject.toml +35 -0
- anip_fastapi-0.11.0/setup.cfg +4 -0
- anip_fastapi-0.11.0/src/anip_fastapi/__init__.py +4 -0
- anip_fastapi-0.11.0/src/anip_fastapi/routes.py +397 -0
- anip_fastapi-0.11.0/src/anip_fastapi.egg-info/PKG-INFO +19 -0
- anip_fastapi-0.11.0/src/anip_fastapi.egg-info/SOURCES.txt +10 -0
- anip_fastapi-0.11.0/src/anip_fastapi.egg-info/dependency_links.txt +1 -0
- anip_fastapi-0.11.0/src/anip_fastapi.egg-info/requires.txt +6 -0
- anip_fastapi-0.11.0/src/anip_fastapi.egg-info/top_level.txt +1 -0
- anip_fastapi-0.11.0/tests/test_routes.py +357 -0
|
@@ -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,15 @@
|
|
|
1
|
+
# anip-fastapi
|
|
2
|
+
|
|
3
|
+
ANIP FastAPI bindings — mount an ANIPService as HTTP routes
|
|
4
|
+
|
|
5
|
+
Part of the [ANIP](https://github.com/anip-protocol/anip) protocol ecosystem.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install anip-fastapi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [ANIP repository](https://github.com/anip-protocol/anip) for full documentation.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "anip-fastapi"
|
|
3
|
+
version = "0.11.0"
|
|
4
|
+
description = "ANIP FastAPI bindings — mount an ANIPService as HTTP routes"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"anip-service==0.11.0",
|
|
8
|
+
"fastapi>=0.115.0",
|
|
9
|
+
]
|
|
10
|
+
authors = [{ name = "ANIP Protocol", email = "team@anip.dev" }]
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
keywords = ["anip", "agent", "protocol"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"License :: OSI Approved :: Apache Software License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=8.0", "httpx>=0.27.0"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Repository = "https://github.com/anip-protocol/anip"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools>=68.0"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
@@ -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,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/anip_fastapi/__init__.py
|
|
4
|
+
src/anip_fastapi/routes.py
|
|
5
|
+
src/anip_fastapi.egg-info/PKG-INFO
|
|
6
|
+
src/anip_fastapi.egg-info/SOURCES.txt
|
|
7
|
+
src/anip_fastapi.egg-info/dependency_links.txt
|
|
8
|
+
src/anip_fastapi.egg-info/requires.txt
|
|
9
|
+
src/anip_fastapi.egg-info/top_level.txt
|
|
10
|
+
tests/test_routes.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
anip_fastapi
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
from anip_service import ANIPService, Capability, ANIPError
|
|
5
|
+
from anip_fastapi import mount_anip
|
|
6
|
+
from anip_core import CapabilityDeclaration, CapabilityInput, CapabilityOutput, ResponseMode, SideEffect, SideEffectType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _greet_cap():
|
|
10
|
+
return Capability(
|
|
11
|
+
declaration=CapabilityDeclaration(
|
|
12
|
+
name="greet",
|
|
13
|
+
description="Say hello",
|
|
14
|
+
contract_version="1.0",
|
|
15
|
+
inputs=[CapabilityInput(name="name", type="string", required=True, description="Who")],
|
|
16
|
+
output=CapabilityOutput(type="object", fields=["message"]),
|
|
17
|
+
side_effect=SideEffect(type=SideEffectType.READ, rollback_window="not_applicable"),
|
|
18
|
+
minimum_scope=["greet"],
|
|
19
|
+
),
|
|
20
|
+
handler=lambda ctx, params: {"message": f"Hello, {params['name']}!"},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
API_KEY = "test-key-123"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def client():
|
|
29
|
+
service = ANIPService(
|
|
30
|
+
service_id="test-service",
|
|
31
|
+
capabilities=[_greet_cap()],
|
|
32
|
+
storage=":memory:",
|
|
33
|
+
authenticate=lambda bearer: "test-agent" if bearer == API_KEY else None,
|
|
34
|
+
)
|
|
35
|
+
app = FastAPI()
|
|
36
|
+
mount_anip(app, service)
|
|
37
|
+
return TestClient(app)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestDiscoveryRoutes:
|
|
41
|
+
def test_well_known_anip(self, client):
|
|
42
|
+
resp = client.get("/.well-known/anip")
|
|
43
|
+
assert resp.status_code == 200
|
|
44
|
+
data = resp.json()
|
|
45
|
+
assert "anip_discovery" in data
|
|
46
|
+
assert "greet" in data["anip_discovery"]["capabilities"]
|
|
47
|
+
|
|
48
|
+
def test_jwks(self, client):
|
|
49
|
+
resp = client.get("/.well-known/jwks.json")
|
|
50
|
+
assert resp.status_code == 200
|
|
51
|
+
data = resp.json()
|
|
52
|
+
assert "keys" in data
|
|
53
|
+
|
|
54
|
+
def test_manifest(self, client):
|
|
55
|
+
resp = client.get("/anip/manifest")
|
|
56
|
+
assert resp.status_code == 200
|
|
57
|
+
assert "X-ANIP-Signature" in resp.headers
|
|
58
|
+
|
|
59
|
+
def test_checkpoints_list(self, client):
|
|
60
|
+
resp = client.get("/anip/checkpoints")
|
|
61
|
+
assert resp.status_code == 200
|
|
62
|
+
assert "checkpoints" in resp.json()
|
|
63
|
+
|
|
64
|
+
def test_checkpoint_not_found(self, client):
|
|
65
|
+
resp = client.get("/anip/checkpoints/ckpt-nonexistent")
|
|
66
|
+
assert resp.status_code == 404
|
|
67
|
+
data = resp.json()
|
|
68
|
+
assert data["success"] is False
|
|
69
|
+
assert data["failure"]["type"] == "not_found"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TestInvokeRoutes:
|
|
73
|
+
def _get_token(self, client):
|
|
74
|
+
resp = client.post(
|
|
75
|
+
"/anip/tokens",
|
|
76
|
+
json={"scope": ["greet"], "capability": "greet"},
|
|
77
|
+
headers={"Authorization": f"Bearer {API_KEY}"},
|
|
78
|
+
)
|
|
79
|
+
assert resp.status_code == 200
|
|
80
|
+
return resp.json()["token"]
|
|
81
|
+
|
|
82
|
+
def test_invoke_response_has_invocation_id(self, client):
|
|
83
|
+
"""Invoke response should include invocation_id."""
|
|
84
|
+
token = self._get_token(client)
|
|
85
|
+
resp = client.post(
|
|
86
|
+
"/anip/invoke/greet",
|
|
87
|
+
json={"parameters": {"name": "World"}},
|
|
88
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
89
|
+
)
|
|
90
|
+
assert resp.status_code == 200
|
|
91
|
+
data = resp.json()
|
|
92
|
+
assert data["success"] is True
|
|
93
|
+
assert data["invocation_id"].startswith("inv-")
|
|
94
|
+
|
|
95
|
+
def test_invoke_passes_client_reference_id(self, client):
|
|
96
|
+
"""Invoke should echo back client_reference_id when provided."""
|
|
97
|
+
token = self._get_token(client)
|
|
98
|
+
resp = client.post(
|
|
99
|
+
"/anip/invoke/greet",
|
|
100
|
+
json={
|
|
101
|
+
"parameters": {"name": "World"},
|
|
102
|
+
"client_reference_id": "my-ref-42",
|
|
103
|
+
},
|
|
104
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
105
|
+
)
|
|
106
|
+
assert resp.status_code == 200
|
|
107
|
+
data = resp.json()
|
|
108
|
+
assert data["success"] is True
|
|
109
|
+
assert data["client_reference_id"] == "my-ref-42"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestPermissionsRoute:
|
|
113
|
+
def _get_token(self, client, scope=None):
|
|
114
|
+
resp = client.post(
|
|
115
|
+
"/anip/tokens",
|
|
116
|
+
json={"scope": scope or ["greet"], "capability": "greet"},
|
|
117
|
+
headers={"Authorization": f"Bearer {API_KEY}"},
|
|
118
|
+
)
|
|
119
|
+
assert resp.status_code == 200
|
|
120
|
+
return resp.json()["token"]
|
|
121
|
+
|
|
122
|
+
def test_permissions_returns_available(self, client):
|
|
123
|
+
token = self._get_token(client)
|
|
124
|
+
resp = client.post(
|
|
125
|
+
"/anip/permissions",
|
|
126
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
127
|
+
)
|
|
128
|
+
assert resp.status_code == 200
|
|
129
|
+
data = resp.json()
|
|
130
|
+
assert "available" in data
|
|
131
|
+
assert "restricted" in data
|
|
132
|
+
assert "denied" in data
|
|
133
|
+
cap_names = [c["capability"] for c in data["available"]]
|
|
134
|
+
assert "greet" in cap_names
|
|
135
|
+
|
|
136
|
+
def test_permissions_shows_restricted_for_missing_scope(self, client):
|
|
137
|
+
resp = client.post(
|
|
138
|
+
"/anip/tokens",
|
|
139
|
+
json={"scope": ["unrelated"], "capability": "greet"},
|
|
140
|
+
headers={"Authorization": f"Bearer {API_KEY}"},
|
|
141
|
+
)
|
|
142
|
+
assert resp.status_code == 200
|
|
143
|
+
token = resp.json()["token"]
|
|
144
|
+
resp = client.post(
|
|
145
|
+
"/anip/permissions",
|
|
146
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
147
|
+
)
|
|
148
|
+
assert resp.status_code == 200
|
|
149
|
+
data = resp.json()
|
|
150
|
+
restricted_names = [c["capability"] for c in data["restricted"]]
|
|
151
|
+
assert "greet" in restricted_names
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TestAuditRoute:
|
|
155
|
+
def test_audit_returns_entries(self, client):
|
|
156
|
+
resp = client.post(
|
|
157
|
+
"/anip/tokens",
|
|
158
|
+
json={"scope": ["greet"], "capability": "greet"},
|
|
159
|
+
headers={"Authorization": f"Bearer {API_KEY}"},
|
|
160
|
+
)
|
|
161
|
+
token = resp.json()["token"]
|
|
162
|
+
client.post(
|
|
163
|
+
"/anip/invoke/greet",
|
|
164
|
+
json={"parameters": {"name": "World"}},
|
|
165
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
166
|
+
)
|
|
167
|
+
resp = client.post(
|
|
168
|
+
"/anip/audit",
|
|
169
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
170
|
+
)
|
|
171
|
+
assert resp.status_code == 200
|
|
172
|
+
data = resp.json()
|
|
173
|
+
assert "entries" in data
|
|
174
|
+
assert "count" in data
|
|
175
|
+
assert data["count"] >= 1
|
|
176
|
+
|
|
177
|
+
def test_audit_with_capability_filter(self, client):
|
|
178
|
+
resp = client.post(
|
|
179
|
+
"/anip/tokens",
|
|
180
|
+
json={"scope": ["greet"], "capability": "greet"},
|
|
181
|
+
headers={"Authorization": f"Bearer {API_KEY}"},
|
|
182
|
+
)
|
|
183
|
+
token = resp.json()["token"]
|
|
184
|
+
client.post(
|
|
185
|
+
"/anip/invoke/greet",
|
|
186
|
+
json={"parameters": {"name": "World"}},
|
|
187
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
188
|
+
)
|
|
189
|
+
resp = client.post(
|
|
190
|
+
"/anip/audit?capability=greet",
|
|
191
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
192
|
+
)
|
|
193
|
+
assert resp.status_code == 200
|
|
194
|
+
data = resp.json()
|
|
195
|
+
assert data["capability_filter"] == "greet"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _streaming_cap():
|
|
199
|
+
async def handler(ctx, params):
|
|
200
|
+
await ctx.emit_progress({"step": 1, "status": "working"})
|
|
201
|
+
return {"answer": 42}
|
|
202
|
+
|
|
203
|
+
return Capability(
|
|
204
|
+
declaration=CapabilityDeclaration(
|
|
205
|
+
name="analyze",
|
|
206
|
+
description="Analyze something",
|
|
207
|
+
contract_version="1.0",
|
|
208
|
+
inputs=[CapabilityInput(name="x", type="string", required=True, description="input")],
|
|
209
|
+
output=CapabilityOutput(type="object", fields=["answer"]),
|
|
210
|
+
side_effect=SideEffect(type=SideEffectType.READ, rollback_window="not_applicable"),
|
|
211
|
+
minimum_scope=["analyze"],
|
|
212
|
+
response_modes=[ResponseMode.UNARY, ResponseMode.STREAMING],
|
|
213
|
+
),
|
|
214
|
+
handler=handler,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@pytest.fixture
|
|
219
|
+
def streaming_client():
|
|
220
|
+
service = ANIPService(
|
|
221
|
+
service_id="test-service",
|
|
222
|
+
capabilities=[_greet_cap(), _streaming_cap()],
|
|
223
|
+
storage=":memory:",
|
|
224
|
+
authenticate=lambda bearer: "test-agent" if bearer == API_KEY else None,
|
|
225
|
+
)
|
|
226
|
+
app = FastAPI()
|
|
227
|
+
mount_anip(app, service)
|
|
228
|
+
return TestClient(app)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TestStreamingRoutes:
|
|
232
|
+
def test_streaming_returns_sse(self, streaming_client):
|
|
233
|
+
"""POST with stream:true should return text/event-stream."""
|
|
234
|
+
resp = streaming_client.post(
|
|
235
|
+
"/anip/tokens",
|
|
236
|
+
json={"subject": "test-agent", "scope": ["analyze"], "capability": "analyze"},
|
|
237
|
+
headers={"Authorization": f"Bearer {API_KEY}"},
|
|
238
|
+
)
|
|
239
|
+
assert resp.status_code == 200
|
|
240
|
+
jwt_str = resp.json()["token"]
|
|
241
|
+
|
|
242
|
+
resp = streaming_client.post(
|
|
243
|
+
"/anip/invoke/analyze",
|
|
244
|
+
json={"parameters": {"x": "test"}, "stream": True},
|
|
245
|
+
headers={"Authorization": f"Bearer {jwt_str}"},
|
|
246
|
+
)
|
|
247
|
+
assert resp.status_code == 200
|
|
248
|
+
assert "text/event-stream" in resp.headers.get("content-type", "")
|
|
249
|
+
body = resp.text
|
|
250
|
+
assert "event: progress" in body
|
|
251
|
+
assert "event: completed" in body
|
|
252
|
+
assert '"answer": 42' in body or '"answer":42' in body
|
|
253
|
+
|
|
254
|
+
def test_streaming_rejected_for_unary_cap(self, streaming_client):
|
|
255
|
+
"""stream:true on a unary-only capability should fail."""
|
|
256
|
+
resp = streaming_client.post(
|
|
257
|
+
"/anip/tokens",
|
|
258
|
+
json={"subject": "test-agent", "scope": ["greet"], "capability": "greet"},
|
|
259
|
+
headers={"Authorization": f"Bearer {API_KEY}"},
|
|
260
|
+
)
|
|
261
|
+
jwt_str = resp.json()["token"]
|
|
262
|
+
|
|
263
|
+
resp = streaming_client.post(
|
|
264
|
+
"/anip/invoke/greet",
|
|
265
|
+
json={"parameters": {"name": "world"}, "stream": True},
|
|
266
|
+
headers={"Authorization": f"Bearer {jwt_str}"},
|
|
267
|
+
)
|
|
268
|
+
assert resp.status_code == 400
|
|
269
|
+
data = resp.json()
|
|
270
|
+
assert data["failure"]["type"] == "streaming_not_supported"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --- Health endpoint tests ---
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pytest.fixture
|
|
277
|
+
def health_client():
|
|
278
|
+
service = ANIPService(
|
|
279
|
+
service_id="test-service",
|
|
280
|
+
capabilities=[_greet_cap()],
|
|
281
|
+
storage=":memory:",
|
|
282
|
+
authenticate=lambda bearer: "test-agent" if bearer == API_KEY else None,
|
|
283
|
+
)
|
|
284
|
+
app = FastAPI()
|
|
285
|
+
mount_anip(app, service, health_endpoint=True)
|
|
286
|
+
return TestClient(app)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestAuthErrors:
|
|
290
|
+
def test_token_endpoint_without_auth_returns_anip_failure(self, client):
|
|
291
|
+
resp = client.post("/anip/tokens", json={"scope": ["greet"]})
|
|
292
|
+
assert resp.status_code == 401
|
|
293
|
+
data = resp.json()
|
|
294
|
+
assert data["success"] is False
|
|
295
|
+
assert data["failure"]["type"] == "authentication_required"
|
|
296
|
+
assert data["failure"]["resolution"]["action"] == "provide_api_key"
|
|
297
|
+
assert data["failure"]["retry"] is True
|
|
298
|
+
|
|
299
|
+
def test_invoke_without_auth_returns_anip_failure(self, client):
|
|
300
|
+
resp = client.post("/anip/invoke/greet", json={"parameters": {"name": "X"}})
|
|
301
|
+
assert resp.status_code == 401
|
|
302
|
+
data = resp.json()
|
|
303
|
+
assert data["success"] is False
|
|
304
|
+
assert data["failure"]["type"] == "authentication_required"
|
|
305
|
+
assert data["failure"]["resolution"]["action"] == "obtain_delegation_token"
|
|
306
|
+
assert data["failure"]["retry"] is True
|
|
307
|
+
|
|
308
|
+
def test_permissions_without_auth_returns_anip_failure(self, client):
|
|
309
|
+
resp = client.post("/anip/permissions")
|
|
310
|
+
assert resp.status_code == 401
|
|
311
|
+
data = resp.json()
|
|
312
|
+
assert data["success"] is False
|
|
313
|
+
assert data["failure"]["type"] == "authentication_required"
|
|
314
|
+
assert data["failure"]["resolution"]["action"] == "obtain_delegation_token"
|
|
315
|
+
|
|
316
|
+
def test_audit_without_auth_returns_anip_failure(self, client):
|
|
317
|
+
resp = client.post("/anip/audit")
|
|
318
|
+
assert resp.status_code == 401
|
|
319
|
+
data = resp.json()
|
|
320
|
+
assert data["success"] is False
|
|
321
|
+
assert data["failure"]["type"] == "authentication_required"
|
|
322
|
+
assert data["failure"]["resolution"]["action"] == "obtain_delegation_token"
|
|
323
|
+
|
|
324
|
+
def test_invoke_with_invalid_token_returns_structured_error(self, client):
|
|
325
|
+
resp = client.post(
|
|
326
|
+
"/anip/invoke/greet",
|
|
327
|
+
json={"parameters": {"name": "X"}},
|
|
328
|
+
headers={"Authorization": "Bearer not-a-valid-jwt"},
|
|
329
|
+
)
|
|
330
|
+
assert resp.status_code == 401
|
|
331
|
+
data = resp.json()
|
|
332
|
+
assert data["success"] is False
|
|
333
|
+
assert data["failure"]["type"] == "invalid_token"
|
|
334
|
+
|
|
335
|
+
def test_permissions_with_invalid_token_returns_structured_error(self, client):
|
|
336
|
+
resp = client.post(
|
|
337
|
+
"/anip/permissions",
|
|
338
|
+
headers={"Authorization": "Bearer not-a-valid-jwt"},
|
|
339
|
+
)
|
|
340
|
+
assert resp.status_code == 401
|
|
341
|
+
data = resp.json()
|
|
342
|
+
assert data["success"] is False
|
|
343
|
+
assert data["failure"]["type"] == "invalid_token"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class TestHealthEndpoint:
|
|
347
|
+
def test_health_endpoint_disabled_by_default(self, client):
|
|
348
|
+
resp = client.get("/-/health")
|
|
349
|
+
assert resp.status_code in (404, 405)
|
|
350
|
+
|
|
351
|
+
def test_health_endpoint_returns_report(self, health_client):
|
|
352
|
+
resp = health_client.get("/-/health")
|
|
353
|
+
assert resp.status_code == 200
|
|
354
|
+
data = resp.json()
|
|
355
|
+
assert data["status"] in ("healthy", "degraded", "unhealthy")
|
|
356
|
+
assert "storage" in data
|
|
357
|
+
assert "retention" in data
|