anip-graphql 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_graphql-0.11.0/PKG-INFO +20 -0
- anip_graphql-0.11.0/README.md +15 -0
- anip_graphql-0.11.0/pyproject.toml +39 -0
- anip_graphql-0.11.0/setup.cfg +4 -0
- anip_graphql-0.11.0/src/anip_graphql/__init__.py +4 -0
- anip_graphql-0.11.0/src/anip_graphql/routes.py +157 -0
- anip_graphql-0.11.0/src/anip_graphql/translation.py +161 -0
- anip_graphql-0.11.0/src/anip_graphql.egg-info/PKG-INFO +20 -0
- anip_graphql-0.11.0/src/anip_graphql.egg-info/SOURCES.txt +11 -0
- anip_graphql-0.11.0/src/anip_graphql.egg-info/dependency_links.txt +1 -0
- anip_graphql-0.11.0/src/anip_graphql.egg-info/requires.txt +7 -0
- anip_graphql-0.11.0/src/anip_graphql.egg-info/top_level.txt +1 -0
- anip_graphql-0.11.0/tests/test_graphql.py +172 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anip-graphql
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: ANIP GraphQL bindings — expose ANIPService capabilities via GraphQL
|
|
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
|
+
Requires-Dist: ariadne>=0.24.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# anip-graphql
|
|
2
|
+
|
|
3
|
+
ANIP GraphQL bindings — expose ANIPService capabilities via GraphQL
|
|
4
|
+
|
|
5
|
+
Part of the [ANIP](https://github.com/anip-protocol/anip) protocol ecosystem.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install anip-graphql
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [ANIP repository](https://github.com/anip-protocol/anip) for full documentation.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "anip-graphql"
|
|
3
|
+
version = "0.11.0"
|
|
4
|
+
description = "ANIP GraphQL bindings — expose ANIPService capabilities via GraphQL"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"anip-service==0.11.0",
|
|
8
|
+
"fastapi>=0.115.0",
|
|
9
|
+
"ariadne>=0.24.0",
|
|
10
|
+
]
|
|
11
|
+
authors = [{ name = "ANIP Protocol", email = "team@anip.dev" }]
|
|
12
|
+
license = { text = "Apache-2.0" }
|
|
13
|
+
keywords = ["anip", "agent", "protocol"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8.0",
|
|
25
|
+
"httpx>=0.27.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/anip-protocol/anip"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["setuptools>=68.0"]
|
|
33
|
+
build-backend = "setuptools.build_meta"
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
asyncio_mode = "auto"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""ANIP GraphQL bindings — mount a GraphQL endpoint on a FastAPI app."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ariadne import QueryType, MutationType, ScalarType, make_executable_schema
|
|
7
|
+
from ariadne.asgi import GraphQL
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.responses import PlainTextResponse
|
|
10
|
+
|
|
11
|
+
from anip_service import ANIPService, ANIPError
|
|
12
|
+
from .translation import (
|
|
13
|
+
generate_schema,
|
|
14
|
+
build_graphql_response,
|
|
15
|
+
to_camel_case,
|
|
16
|
+
to_snake_case,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _resolve_auth(request, service: ANIPService, capability_name: str):
|
|
21
|
+
"""Resolve auth — JWT first, then API key."""
|
|
22
|
+
auth = request.headers.get("authorization", "")
|
|
23
|
+
if not auth.startswith("Bearer "):
|
|
24
|
+
return None
|
|
25
|
+
bearer = auth[7:].strip()
|
|
26
|
+
|
|
27
|
+
# Try as JWT first — preserves original delegation chain
|
|
28
|
+
jwt_error = None
|
|
29
|
+
try:
|
|
30
|
+
return await service.resolve_bearer_token(bearer)
|
|
31
|
+
except ANIPError as e:
|
|
32
|
+
jwt_error = e
|
|
33
|
+
|
|
34
|
+
# Try as API key — only if JWT failed
|
|
35
|
+
principal = await service.authenticate_bearer(bearer)
|
|
36
|
+
if principal:
|
|
37
|
+
cap_decl = service.get_capability_declaration(capability_name)
|
|
38
|
+
min_scope = cap_decl.minimum_scope if cap_decl else []
|
|
39
|
+
token_result = await service.issue_token(principal, {
|
|
40
|
+
"subject": "adapter:anip-graphql",
|
|
41
|
+
"scope": min_scope if min_scope else ["*"],
|
|
42
|
+
"capability": capability_name,
|
|
43
|
+
"purpose_parameters": {"source": "graphql"},
|
|
44
|
+
})
|
|
45
|
+
return await service.resolve_bearer_token(token_result["token"])
|
|
46
|
+
|
|
47
|
+
# Surface the original JWT error
|
|
48
|
+
if jwt_error:
|
|
49
|
+
raise jwt_error
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _make_resolver(capability_name: str, service: ANIPService):
|
|
54
|
+
"""Create a resolver for a given capability."""
|
|
55
|
+
|
|
56
|
+
async def resolver(_obj: Any, info: Any, **kwargs: Any) -> dict[str, Any]:
|
|
57
|
+
# Convert camelCase args back to snake_case
|
|
58
|
+
arguments = {to_snake_case(k): v for k, v in kwargs.items()}
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
token = await _resolve_auth(info.context["request"], service, capability_name)
|
|
62
|
+
except ANIPError as e:
|
|
63
|
+
return build_graphql_response({
|
|
64
|
+
"success": False,
|
|
65
|
+
"failure": {
|
|
66
|
+
"type": e.error_type,
|
|
67
|
+
"detail": e.detail,
|
|
68
|
+
"resolution": e.resolution,
|
|
69
|
+
"retry": e.retry,
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if token is None:
|
|
74
|
+
return build_graphql_response({
|
|
75
|
+
"success": False,
|
|
76
|
+
"failure": {
|
|
77
|
+
"type": "authentication_required",
|
|
78
|
+
"detail": "Authorization header required",
|
|
79
|
+
"resolution": {"action": "provide_credentials"},
|
|
80
|
+
"retry": True,
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
result = await service.invoke(capability_name, token, arguments)
|
|
86
|
+
except ANIPError as e:
|
|
87
|
+
return build_graphql_response({
|
|
88
|
+
"success": False,
|
|
89
|
+
"failure": {
|
|
90
|
+
"type": e.error_type,
|
|
91
|
+
"detail": e.detail,
|
|
92
|
+
"resolution": e.resolution,
|
|
93
|
+
"retry": e.retry,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return build_graphql_response(result)
|
|
98
|
+
|
|
99
|
+
return resolver
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def mount_anip_graphql(
|
|
103
|
+
app: FastAPI,
|
|
104
|
+
service: ANIPService,
|
|
105
|
+
*,
|
|
106
|
+
path: str = "/graphql",
|
|
107
|
+
prefix: str = "",
|
|
108
|
+
debug: bool = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Mount a GraphQL endpoint on a FastAPI app.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
app: FastAPI app instance.
|
|
114
|
+
service: ANIPService to expose.
|
|
115
|
+
path: GraphQL endpoint path. Default: "/graphql".
|
|
116
|
+
prefix: URL prefix.
|
|
117
|
+
"""
|
|
118
|
+
manifest = service.get_manifest()
|
|
119
|
+
capabilities = {}
|
|
120
|
+
for name in manifest.capabilities:
|
|
121
|
+
decl = service.get_capability_declaration(name)
|
|
122
|
+
if decl:
|
|
123
|
+
capabilities[name] = decl
|
|
124
|
+
|
|
125
|
+
schema_sdl = generate_schema(capabilities)
|
|
126
|
+
|
|
127
|
+
# Build Ariadne resolvers
|
|
128
|
+
query = QueryType()
|
|
129
|
+
mutation = MutationType()
|
|
130
|
+
json_scalar = ScalarType("JSON")
|
|
131
|
+
|
|
132
|
+
@json_scalar.serializer
|
|
133
|
+
def serialize_json(value):
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
@json_scalar.value_parser
|
|
137
|
+
def parse_json_value(value):
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
for name, decl in capabilities.items():
|
|
141
|
+
camel_name = to_camel_case(name)
|
|
142
|
+
resolver_fn = _make_resolver(name, service)
|
|
143
|
+
se_type = decl.side_effect.type.value if hasattr(decl.side_effect.type, "value") else str(decl.side_effect.type)
|
|
144
|
+
if se_type == "read":
|
|
145
|
+
query.field(camel_name)(resolver_fn)
|
|
146
|
+
else:
|
|
147
|
+
mutation.field(camel_name)(resolver_fn)
|
|
148
|
+
|
|
149
|
+
schema = make_executable_schema(schema_sdl, query, mutation, json_scalar)
|
|
150
|
+
graphql_app = GraphQL(schema, debug=debug)
|
|
151
|
+
|
|
152
|
+
full_path = f"{prefix}{path}"
|
|
153
|
+
app.mount(full_path, graphql_app)
|
|
154
|
+
|
|
155
|
+
@app.get(f"{prefix}/schema.graphql")
|
|
156
|
+
async def get_schema() -> PlainTextResponse:
|
|
157
|
+
return PlainTextResponse(schema_sdl, media_type="text/plain")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""ANIP → GraphQL translation layer.
|
|
2
|
+
|
|
3
|
+
Generates SDL schema from ANIP capabilities with custom directives,
|
|
4
|
+
camelCase field names, and query/mutation separation.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from anip_core.models import CapabilityDeclaration
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_camel_case(snake: str) -> str:
|
|
14
|
+
parts = snake.split("_")
|
|
15
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_snake_case(camel: str) -> str:
|
|
19
|
+
import re
|
|
20
|
+
return re.sub(r"([A-Z])", r"_\1", camel).lower().lstrip("_")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _to_pascal_case(snake: str) -> str:
|
|
24
|
+
return "".join(p.capitalize() for p in snake.split("_"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_GQL_TYPE_MAP = {
|
|
28
|
+
"string": "String",
|
|
29
|
+
"integer": "Int",
|
|
30
|
+
"number": "Float",
|
|
31
|
+
"boolean": "Boolean",
|
|
32
|
+
"object": "JSON",
|
|
33
|
+
"array": "JSON",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generate_schema(capabilities: dict[str, CapabilityDeclaration]) -> str:
|
|
38
|
+
"""Generate a complete GraphQL SDL schema from ANIP capabilities."""
|
|
39
|
+
lines = [
|
|
40
|
+
'directive @anipSideEffect(type: String!, rollbackWindow: String) on FIELD_DEFINITION',
|
|
41
|
+
'directive @anipCost(certainty: String!, currency: String, rangeMin: Float, rangeMax: Float) on FIELD_DEFINITION',
|
|
42
|
+
'directive @anipRequires(capabilities: [String!]!) on FIELD_DEFINITION',
|
|
43
|
+
'directive @anipScope(scopes: [String!]!) on FIELD_DEFINITION',
|
|
44
|
+
'',
|
|
45
|
+
'scalar JSON',
|
|
46
|
+
'',
|
|
47
|
+
'type CostActual { financial: FinancialCost, varianceFromEstimate: String }',
|
|
48
|
+
'type FinancialCost { amount: Float, currency: String }',
|
|
49
|
+
'type ANIPFailure { type: String!, detail: String!, resolution: Resolution, retry: Boolean! }',
|
|
50
|
+
'type Resolution { action: String!, requires: String, grantableBy: String }',
|
|
51
|
+
'',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
queries = []
|
|
55
|
+
mutations = []
|
|
56
|
+
|
|
57
|
+
for name, decl in capabilities.items():
|
|
58
|
+
pascal = _to_pascal_case(name)
|
|
59
|
+
camel = to_camel_case(name)
|
|
60
|
+
|
|
61
|
+
lines.append(f"type {pascal}Result {{ success: Boolean!, result: JSON, costActual: CostActual, failure: ANIPFailure }}")
|
|
62
|
+
|
|
63
|
+
args = _build_field_args(decl)
|
|
64
|
+
directives = _build_directives(decl)
|
|
65
|
+
field_line = f" {camel}{args}: {pascal}Result! {directives}"
|
|
66
|
+
|
|
67
|
+
se_type = decl.side_effect.type.value if hasattr(decl.side_effect.type, "value") else str(decl.side_effect.type)
|
|
68
|
+
if se_type == "read":
|
|
69
|
+
queries.append(field_line)
|
|
70
|
+
else:
|
|
71
|
+
mutations.append(field_line)
|
|
72
|
+
|
|
73
|
+
lines.append("")
|
|
74
|
+
if queries:
|
|
75
|
+
lines.append("type Query {")
|
|
76
|
+
lines.extend(queries)
|
|
77
|
+
lines.append("}")
|
|
78
|
+
if mutations:
|
|
79
|
+
lines.append("type Mutation {")
|
|
80
|
+
lines.extend(mutations)
|
|
81
|
+
lines.append("}")
|
|
82
|
+
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_field_args(decl: CapabilityDeclaration) -> str:
|
|
87
|
+
if not decl.inputs:
|
|
88
|
+
return ""
|
|
89
|
+
args = []
|
|
90
|
+
for inp in decl.inputs:
|
|
91
|
+
gql_type = _GQL_TYPE_MAP.get(inp.type, "String")
|
|
92
|
+
if inp.required:
|
|
93
|
+
gql_type += "!"
|
|
94
|
+
args.append(f"{to_camel_case(inp.name)}: {gql_type}")
|
|
95
|
+
return "(" + ", ".join(args) + ")"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_directives(decl: CapabilityDeclaration) -> str:
|
|
99
|
+
parts = []
|
|
100
|
+
se_type = decl.side_effect.type.value if hasattr(decl.side_effect.type, "value") else str(decl.side_effect.type)
|
|
101
|
+
rollback = decl.side_effect.rollback_window
|
|
102
|
+
|
|
103
|
+
se_dir = f'@anipSideEffect(type: "{se_type}"'
|
|
104
|
+
if rollback:
|
|
105
|
+
se_dir += f', rollbackWindow: "{rollback}"'
|
|
106
|
+
se_dir += ")"
|
|
107
|
+
parts.append(se_dir)
|
|
108
|
+
|
|
109
|
+
if decl.cost:
|
|
110
|
+
certainty = decl.cost.certainty.value if hasattr(decl.cost.certainty, "value") else str(decl.cost.certainty)
|
|
111
|
+
cost_dir = f'@anipCost(certainty: "{certainty}"'
|
|
112
|
+
if decl.cost.financial:
|
|
113
|
+
financial = decl.cost.financial
|
|
114
|
+
currency = financial.get("currency") if isinstance(financial, dict) else getattr(financial, "currency", None)
|
|
115
|
+
if currency:
|
|
116
|
+
cost_dir += f', currency: "{currency}"'
|
|
117
|
+
cost_dir += ")"
|
|
118
|
+
parts.append(cost_dir)
|
|
119
|
+
|
|
120
|
+
if decl.requires:
|
|
121
|
+
cap_names = ", ".join(f'"{r.capability}"' for r in decl.requires)
|
|
122
|
+
parts.append(f"@anipRequires(capabilities: [{cap_names}])")
|
|
123
|
+
|
|
124
|
+
if decl.minimum_scope:
|
|
125
|
+
scope_vals = ", ".join(f'"{s}"' for s in decl.minimum_scope)
|
|
126
|
+
parts.append(f"@anipScope(scopes: [{scope_vals}])")
|
|
127
|
+
|
|
128
|
+
return " ".join(parts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_graphql_response(result: dict[str, Any]) -> dict[str, Any]:
|
|
132
|
+
"""Map ANIP invoke response to GraphQL result shape (camelCase)."""
|
|
133
|
+
response: dict[str, Any] = {
|
|
134
|
+
"success": result.get("success", False),
|
|
135
|
+
"result": result.get("result"),
|
|
136
|
+
"costActual": None,
|
|
137
|
+
"failure": None,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
cost_actual = result.get("cost_actual")
|
|
141
|
+
if cost_actual:
|
|
142
|
+
response["costActual"] = {
|
|
143
|
+
"financial": cost_actual.get("financial"),
|
|
144
|
+
"varianceFromEstimate": cost_actual.get("variance_from_estimate"),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
failure = result.get("failure")
|
|
148
|
+
if failure:
|
|
149
|
+
resolution = failure.get("resolution")
|
|
150
|
+
response["failure"] = {
|
|
151
|
+
"type": failure.get("type", "unknown"),
|
|
152
|
+
"detail": failure.get("detail", ""),
|
|
153
|
+
"resolution": {
|
|
154
|
+
"action": resolution.get("action", ""),
|
|
155
|
+
"requires": resolution.get("requires"),
|
|
156
|
+
"grantableBy": resolution.get("grantable_by"),
|
|
157
|
+
} if resolution else None,
|
|
158
|
+
"retry": failure.get("retry", False),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return response
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anip-graphql
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: ANIP GraphQL bindings — expose ANIPService capabilities via GraphQL
|
|
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
|
+
Requires-Dist: ariadne>=0.24.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/anip_graphql/__init__.py
|
|
4
|
+
src/anip_graphql/routes.py
|
|
5
|
+
src/anip_graphql/translation.py
|
|
6
|
+
src/anip_graphql.egg-info/PKG-INFO
|
|
7
|
+
src/anip_graphql.egg-info/SOURCES.txt
|
|
8
|
+
src/anip_graphql.egg-info/dependency_links.txt
|
|
9
|
+
src/anip_graphql.egg-info/requires.txt
|
|
10
|
+
src/anip_graphql.egg-info/top_level.txt
|
|
11
|
+
tests/test_graphql.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
anip_graphql
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Tests for the anip-graphql package."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from anip_core.models import (
|
|
5
|
+
CapabilityDeclaration, CapabilityInput, CapabilityOutput,
|
|
6
|
+
SideEffect, SideEffectType,
|
|
7
|
+
)
|
|
8
|
+
from anip_graphql.translation import (
|
|
9
|
+
generate_schema, to_camel_case, to_snake_case, build_graphql_response,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
GREET_DECL = CapabilityDeclaration(
|
|
13
|
+
name="greet",
|
|
14
|
+
description="Say hello",
|
|
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
|
+
|
|
21
|
+
BOOK_DECL = CapabilityDeclaration(
|
|
22
|
+
name="book_item",
|
|
23
|
+
description="Book something",
|
|
24
|
+
inputs=[CapabilityInput(name="item_name", type="string", required=True, description="What")],
|
|
25
|
+
output=CapabilityOutput(type="object", fields=["booking_id"]),
|
|
26
|
+
side_effect=SideEffect(type=SideEffectType.IRREVERSIBLE, rollback_window="none"),
|
|
27
|
+
minimum_scope=["book"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestCaseConversion:
|
|
32
|
+
def test_to_camel_case(self):
|
|
33
|
+
assert to_camel_case("search_flights") == "searchFlights"
|
|
34
|
+
assert to_camel_case("book") == "book"
|
|
35
|
+
|
|
36
|
+
def test_to_snake_case(self):
|
|
37
|
+
assert to_snake_case("searchFlights") == "search_flights"
|
|
38
|
+
assert to_snake_case("itemName") == "item_name"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestSDLGeneration:
|
|
42
|
+
def test_generates_query_for_read(self):
|
|
43
|
+
sdl = generate_schema({"greet": GREET_DECL})
|
|
44
|
+
assert "type Query" in sdl
|
|
45
|
+
assert "greet(name: String!): GreetResult!" in sdl
|
|
46
|
+
|
|
47
|
+
def test_generates_mutation_for_irreversible(self):
|
|
48
|
+
sdl = generate_schema({"book_item": BOOK_DECL})
|
|
49
|
+
assert "type Mutation" in sdl
|
|
50
|
+
assert "bookItem(itemName: String!): BookItemResult!" in sdl
|
|
51
|
+
|
|
52
|
+
def test_includes_directives(self):
|
|
53
|
+
sdl = generate_schema({"greet": GREET_DECL})
|
|
54
|
+
assert "@anipSideEffect" in sdl
|
|
55
|
+
assert "@anipScope" in sdl
|
|
56
|
+
|
|
57
|
+
def test_includes_shared_types(self):
|
|
58
|
+
sdl = generate_schema({"greet": GREET_DECL})
|
|
59
|
+
assert "type ANIPFailure" in sdl
|
|
60
|
+
assert "scalar JSON" in sdl
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestResponseMapping:
|
|
64
|
+
def test_success_response(self):
|
|
65
|
+
result = build_graphql_response({"success": True, "result": {"message": "Hi"}})
|
|
66
|
+
assert result["success"] is True
|
|
67
|
+
assert result["result"]["message"] == "Hi"
|
|
68
|
+
|
|
69
|
+
def test_failure_response(self):
|
|
70
|
+
result = build_graphql_response({
|
|
71
|
+
"success": False,
|
|
72
|
+
"failure": {
|
|
73
|
+
"type": "scope_insufficient",
|
|
74
|
+
"detail": "Missing scope",
|
|
75
|
+
"resolution": {"action": "request_scope", "grantable_by": "admin"},
|
|
76
|
+
"retry": False,
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
assert result["success"] is False
|
|
80
|
+
assert result["failure"]["type"] == "scope_insufficient"
|
|
81
|
+
assert result["failure"]["resolution"]["grantableBy"] == "admin"
|
|
82
|
+
|
|
83
|
+
def test_cost_actual_mapping(self):
|
|
84
|
+
result = build_graphql_response({
|
|
85
|
+
"success": True,
|
|
86
|
+
"result": {},
|
|
87
|
+
"cost_actual": {
|
|
88
|
+
"financial": {"amount": 100, "currency": "USD"},
|
|
89
|
+
"variance_from_estimate": "-5%",
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
assert result["costActual"]["financial"]["amount"] == 100
|
|
93
|
+
assert result["costActual"]["varianceFromEstimate"] == "-5%"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestMountIntegration:
|
|
97
|
+
"""Integration tests using a real ANIPService + FastAPI TestClient."""
|
|
98
|
+
|
|
99
|
+
API_KEY = "test-key"
|
|
100
|
+
|
|
101
|
+
@pytest.fixture
|
|
102
|
+
def client(self):
|
|
103
|
+
from fastapi import FastAPI
|
|
104
|
+
from fastapi.testclient import TestClient
|
|
105
|
+
from anip_service import ANIPService, Capability
|
|
106
|
+
from anip_fastapi import mount_anip
|
|
107
|
+
from anip_graphql import mount_anip_graphql
|
|
108
|
+
|
|
109
|
+
service = ANIPService(
|
|
110
|
+
service_id="test-graphql",
|
|
111
|
+
capabilities=[
|
|
112
|
+
Capability(
|
|
113
|
+
declaration=GREET_DECL,
|
|
114
|
+
handler=lambda ctx, params: {"message": f"Hello, {params['name']}!"},
|
|
115
|
+
),
|
|
116
|
+
Capability(
|
|
117
|
+
declaration=BOOK_DECL,
|
|
118
|
+
handler=lambda ctx, params: {"booking_id": "BK-001"},
|
|
119
|
+
),
|
|
120
|
+
],
|
|
121
|
+
authenticate=lambda bearer: "test-agent" if bearer == self.API_KEY else None,
|
|
122
|
+
)
|
|
123
|
+
app = FastAPI()
|
|
124
|
+
mount_anip(app, service) # owns lifecycle via app hooks
|
|
125
|
+
mount_anip_graphql(app, service) # adds GraphQL routes only
|
|
126
|
+
return TestClient(app)
|
|
127
|
+
|
|
128
|
+
def test_query_read_capability(self, client):
|
|
129
|
+
resp = client.post(
|
|
130
|
+
"/graphql",
|
|
131
|
+
json={"query": '{ greet(name: "World") { success result } }'},
|
|
132
|
+
headers={"Authorization": f"Bearer {self.API_KEY}"},
|
|
133
|
+
)
|
|
134
|
+
assert resp.status_code == 200
|
|
135
|
+
data = resp.json()
|
|
136
|
+
assert data["data"]["greet"]["success"] is True
|
|
137
|
+
assert data["data"]["greet"]["result"]["message"] == "Hello, World!"
|
|
138
|
+
|
|
139
|
+
def test_mutation_write_capability(self, client):
|
|
140
|
+
resp = client.post(
|
|
141
|
+
"/graphql",
|
|
142
|
+
json={"query": 'mutation { bookItem(itemName: "x") { success result } }'},
|
|
143
|
+
headers={"Authorization": f"Bearer {self.API_KEY}"},
|
|
144
|
+
)
|
|
145
|
+
assert resp.status_code == 200
|
|
146
|
+
assert resp.json()["data"]["bookItem"]["success"] is True
|
|
147
|
+
|
|
148
|
+
def test_query_with_invalid_jwt_returns_invalid_token(self, client):
|
|
149
|
+
resp = client.post(
|
|
150
|
+
"/graphql",
|
|
151
|
+
json={"query": '{ greet(name: "World") { success failure { type } } }'},
|
|
152
|
+
headers={"Authorization": "Bearer garbage-not-a-jwt"},
|
|
153
|
+
)
|
|
154
|
+
assert resp.status_code == 200
|
|
155
|
+
data = resp.json()
|
|
156
|
+
assert data["data"]["greet"]["success"] is False
|
|
157
|
+
assert data["data"]["greet"]["failure"]["type"] == "invalid_token"
|
|
158
|
+
|
|
159
|
+
def test_query_without_auth_returns_failure(self, client):
|
|
160
|
+
resp = client.post(
|
|
161
|
+
"/graphql",
|
|
162
|
+
json={"query": '{ greet(name: "World") { success failure { type } } }'},
|
|
163
|
+
)
|
|
164
|
+
assert resp.status_code == 200
|
|
165
|
+
data = resp.json()
|
|
166
|
+
assert data["data"]["greet"]["success"] is False
|
|
167
|
+
assert data["data"]["greet"]["failure"]["type"] == "authentication_required"
|
|
168
|
+
|
|
169
|
+
def test_schema_endpoint(self, client):
|
|
170
|
+
resp = client.get("/schema.graphql")
|
|
171
|
+
assert resp.status_code == 200
|
|
172
|
+
assert "type Query" in resp.text
|