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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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,6 @@
1
+ anip-service==0.11.0
2
+ fastapi>=0.115.0
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ httpx>=0.27.0
@@ -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