codi-api-agent 0.3.1__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,309 @@
1
+ """Load a GraphQL API into the same read-only `Catalog` the rest of the pipeline uses.
2
+
3
+ GraphQL doesn't map cleanly onto REST paths, so instead of converting to OpenAPI we read
4
+ the schema natively (from a live endpoint via introspection, or a local SDL `.graphql` file)
5
+ and build one `Tool` per **Query** field. This keeps the agent READ-ONLY by construction:
6
+ mutations and subscriptions are recorded in the catalog's `reference` (so documentation mode can
7
+ DESCRIBE them) but are never turned into callable tools.
8
+
9
+ Each query tool's executor builds a GraphQL query string — `query(...) { field(args) { <auto
10
+ selection> } }` — with an auto-generated selection set (scalar/enum fields, shallow object
11
+ recursion), POSTs `{query, variables}` to the endpoint, and returns the same
12
+ ``{"ok","content","source","error"}`` contract as the OpenAPI executor (reusing `_compact_json`
13
+ for PII redaction + size). GraphQL auth errors are surfaced as ``HTTP 401`` so the agent's
14
+ existing re-auth handling kicks in.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import requests
19
+ from graphql import (
20
+ GraphQLEnumType,
21
+ GraphQLInputObjectType,
22
+ GraphQLInterfaceType,
23
+ GraphQLList,
24
+ GraphQLNonNull,
25
+ GraphQLObjectType,
26
+ GraphQLScalarType,
27
+ build_client_schema,
28
+ build_schema,
29
+ get_introspection_query,
30
+ )
31
+
32
+ import requests
33
+
34
+ from .catalog import Catalog, Tool
35
+ from .openapi_loader import (
36
+ _UA,
37
+ READ_METHODS,
38
+ _compact_json,
39
+ _parse,
40
+ _redact_text,
41
+ _sanitize,
42
+ _unique,
43
+ build_catalog,
44
+ load_openapi,
45
+ )
46
+ from .spec_convert import CONVERTIBLE, convert_to_openapi, detect_format, set_base_url
47
+
48
+ _SCALAR_JSON = {"Int": "integer", "Float": "number", "Boolean": "boolean",
49
+ "String": "string", "ID": "string"}
50
+ _GQL_EXT = (".graphql", ".gql", ".graphqls", ".sdl")
51
+ _AUTH_WORDS = ("unauthorized", "unauthenticated", "not authenticated", "forbidden",
52
+ "access denied", "permission", "login", "auth token", "must be logged in")
53
+
54
+
55
+ # --------------------------------------------------------------------------- #
56
+ # Public entry points
57
+ # --------------------------------------------------------------------------- #
58
+ def load_catalog(source: str, *, graphql: bool = False, endpoint: str | None = None,
59
+ base_url: str | None = None, max_operations: int = 1000, timeout: int = 20,
60
+ auth_header: str | None = None, auth_value: str | None = None) -> Catalog:
61
+ """One entry point for ANY spec format. Detects the source and dispatches:
62
+ - **GraphQL** (flag or `.graphql/.gql/.sdl`) → introspection/SDL loader.
63
+ - **OpenAPI / Swagger** → loaded directly.
64
+ - **Postman collection / RAML 0.8 / API Blueprint** → auto-CONVERTED to OpenAPI (via npx),
65
+ then loaded — so the user can point the agent at a non-OpenAPI spec and it just works.
66
+ `base_url` sets the server for a converted spec whose source left a `{{baseUrl}}`/no host
67
+ (Postman/RAML); `endpoint` is the GraphQL query URL for an SDL file."""
68
+ if graphql or source.lower().rstrip("/").endswith(_GQL_EXT):
69
+ return load_graphql(source, endpoint=endpoint, max_operations=max_operations,
70
+ timeout=timeout, auth_header=auth_header, auth_value=auth_value)
71
+
72
+ # Read the raw source once, then detect its format.
73
+ if source.startswith(("http://", "https://")):
74
+ resp = requests.get(source, timeout=timeout, headers=_UA)
75
+ resp.raise_for_status()
76
+ text, origin = resp.text, source
77
+ else:
78
+ with open(source, encoding="utf-8") as f:
79
+ text, origin = f.read(), None
80
+ fmt = detect_format(text, source)
81
+
82
+ auth = (auth_header, auth_value) if auth_header and auth_value else None
83
+ if fmt in CONVERTIBLE: # Postman / RAML / API Blueprint → convert to OpenAPI, then load
84
+ spec = convert_to_openapi(text, fmt)
85
+ if base_url:
86
+ spec = set_base_url(spec, base_url)
87
+ return build_catalog(spec, origin, methods=READ_METHODS,
88
+ max_operations=max_operations, auth=auth)
89
+ # OpenAPI / Swagger / unknown → parse and load directly (single fetch, reuse the text).
90
+ return build_catalog(_parse(text), origin, methods=READ_METHODS,
91
+ max_operations=max_operations, auth=auth)
92
+
93
+
94
+ def load_graphql(source: str, endpoint: str | None = None, max_operations: int = 1000,
95
+ timeout: int = 20, auth_header: str | None = None,
96
+ auth_value: str | None = None) -> Catalog:
97
+ """Build a read-only Catalog from a GraphQL schema.
98
+
99
+ `source` is a live endpoint URL (introspected) or a local SDL file. For an SDL file you may
100
+ pass `endpoint` (the server to actually query); without it the tools document the schema but
101
+ can't be called. Auth header is injected into both introspection and query calls.
102
+ """
103
+ auth = (auth_header, auth_value) if auth_header and auth_value else None
104
+ schema, endpoint = _load_schema(source, endpoint, timeout, auth)
105
+
106
+ tools: list[Tool] = []
107
+ reference: list[dict] = []
108
+ seen: set[str] = set()
109
+
110
+ qtype = schema.query_type
111
+ if qtype:
112
+ for fname, field in qtype.fields.items():
113
+ if len(tools) >= max_operations:
114
+ break
115
+ name = _unique(_sanitize(fname), seen)
116
+ selection = _selection_set(field.type, depth=2, seen=frozenset())
117
+ arg_types = {a: str(arg.type) for a, arg in field.args.items()}
118
+ tools.append(Tool(
119
+ name=name,
120
+ description=_describe(fname, field),
121
+ parameters=_parameters(field),
122
+ data_type="structured",
123
+ fn=_make_executor(endpoint, fname, selection, arg_types, auth),
124
+ ))
125
+ reference.append(_reference_entry("query", fname, field))
126
+
127
+ # Mutations/subscriptions are documented but NEVER callable (read-only by construction).
128
+ for op_type, t in (("mutation", schema.mutation_type),
129
+ ("subscription", schema.subscription_type)):
130
+ if t:
131
+ for fname, field in t.fields.items():
132
+ reference.append(_reference_entry(op_type, fname, field))
133
+
134
+ return Catalog(tools, reference=reference)
135
+
136
+
137
+ # --------------------------------------------------------------------------- #
138
+ # Schema loading
139
+ # --------------------------------------------------------------------------- #
140
+ def _load_schema(source: str, endpoint: str | None, timeout: int, auth):
141
+ if source.startswith(("http://", "https://")):
142
+ endpoint = endpoint or source
143
+ return build_client_schema(_introspect(source, timeout, auth)), endpoint
144
+ with open(source, encoding="utf-8") as f:
145
+ sdl = f.read()
146
+ # assume_valid: tolerate custom directives/scalars in third-party SDL dumps.
147
+ return build_schema(sdl, assume_valid=True), endpoint
148
+
149
+
150
+ def _introspect(url: str, timeout: int, auth) -> dict:
151
+ headers = {**_UA, "Content-Type": "application/json"}
152
+ if auth:
153
+ headers[auth[0]] = auth[1]
154
+ resp = requests.post(url, json={"query": get_introspection_query(descriptions=True)},
155
+ headers=headers, timeout=timeout)
156
+ resp.raise_for_status()
157
+ payload = resp.json()
158
+ if not payload.get("data"):
159
+ raise ValueError(f"GraphQL introspection failed: {payload.get('errors')}")
160
+ return payload["data"]
161
+
162
+
163
+ # --------------------------------------------------------------------------- #
164
+ # Type / selection helpers
165
+ # --------------------------------------------------------------------------- #
166
+ def _unwrap(t):
167
+ """Strip NonNull/List wrappers to the underlying named type."""
168
+ while isinstance(t, (GraphQLNonNull, GraphQLList)):
169
+ t = t.of_type
170
+ return t
171
+
172
+
173
+ def _json_type(gtype) -> dict:
174
+ """Map a GraphQL type to a JSON-schema fragment for the tool's parameter spec."""
175
+ if isinstance(gtype, GraphQLNonNull):
176
+ return _json_type(gtype.of_type)
177
+ if isinstance(gtype, GraphQLList):
178
+ return {"type": "array", "items": _json_type(gtype.of_type)}
179
+ if isinstance(gtype, GraphQLScalarType):
180
+ return {"type": _SCALAR_JSON.get(gtype.name, "string")}
181
+ if isinstance(gtype, GraphQLEnumType):
182
+ return {"type": "string", "enum": list(gtype.values.keys())}
183
+ if isinstance(gtype, GraphQLInputObjectType):
184
+ return {"type": "object"} # opaque input object — the model supplies a dict
185
+ return {"type": "string"}
186
+
187
+
188
+ def _selection_set(gtype, depth: int, seen: frozenset) -> str:
189
+ """Auto-build a selection set: all scalar/enum fields, recursing shallowly into object
190
+ fields, skipping fields that REQUIRE arguments (can't be auto-filled) and avoiding type
191
+ cycles. Returns '' for leaf (scalar) return types — they need no sub-selection."""
192
+ t = _unwrap(gtype)
193
+ if isinstance(t, (GraphQLScalarType, GraphQLEnumType)):
194
+ return ""
195
+ if not isinstance(t, (GraphQLObjectType, GraphQLInterfaceType)):
196
+ return "" # union/input — skip rather than guess
197
+ if t.name in seen or depth <= 0:
198
+ return "{ __typename }" # minimal valid selection to stop recursion
199
+ seen = seen | {t.name}
200
+ parts: list[str] = []
201
+ for fname, f in t.fields.items():
202
+ if any(isinstance(a.type, GraphQLNonNull) for a in f.args.values()):
203
+ continue # needs required args we can't supply
204
+ inner = _unwrap(f.type)
205
+ if isinstance(inner, (GraphQLScalarType, GraphQLEnumType)):
206
+ parts.append(fname)
207
+ elif isinstance(inner, (GraphQLObjectType, GraphQLInterfaceType)) and depth > 1:
208
+ sub = _selection_set(f.type, depth - 1, seen)
209
+ if sub:
210
+ parts.append(f"{fname} {sub}")
211
+ if len(parts) >= 20: # cap breadth so queries stay reasonable
212
+ break
213
+ return "{ " + " ".join(parts) + " }" if parts else "{ __typename }"
214
+
215
+
216
+ def _describe(fname: str, field) -> str:
217
+ desc = (field.description or "").strip()
218
+ head = f"GraphQL query `{fname}` → {field.type}."
219
+ return (head + (" " + desc if desc else "")).strip()
220
+
221
+
222
+ def _parameters(field) -> dict:
223
+ props: dict = {}
224
+ required: list[str] = []
225
+ for aname, arg in field.args.items():
226
+ sch = _json_type(arg.type)
227
+ if arg.description:
228
+ sch["description"] = arg.description.strip()[:160]
229
+ props[aname] = sch
230
+ if isinstance(arg.type, GraphQLNonNull):
231
+ required.append(aname)
232
+ return {"type": "object", "properties": props, "required": required}
233
+
234
+
235
+ def _reference_entry(op_type: str, fname: str, field) -> dict:
236
+ """Documentation record for one GraphQL field. `method` maps query->GET (callable read),
237
+ mutation->POST, subscription->SUBSCRIPTION so the agent's existing GET/HEAD-vs-write logic
238
+ treats them correctly."""
239
+ method = {"query": "GET", "mutation": "POST", "subscription": "SUBSCRIPTION"}[op_type]
240
+ desc = (field.description or "").strip()
241
+ return {
242
+ "name": _sanitize(fname),
243
+ "method": method,
244
+ "path": fname,
245
+ "summary": (desc.split("\n", 1)[0][:120] or f"{op_type} → {field.type}"),
246
+ "description": desc[:300],
247
+ "params": [
248
+ {"name": a, "in": "query", "required": isinstance(arg.type, GraphQLNonNull),
249
+ "type": _json_type(arg.type).get("type", "string"), "example": None}
250
+ for a, arg in field.args.items()
251
+ ],
252
+ "body": [],
253
+ }
254
+
255
+
256
+ # --------------------------------------------------------------------------- #
257
+ # Execution
258
+ # --------------------------------------------------------------------------- #
259
+ def _build_query(field_name: str, selection: str, arg_gql_types: dict, provided: dict) -> str:
260
+ """Compose a GraphQL query using ONLY the args the model supplied (so optional args are
261
+ simply omitted and required-but-missing args surface as a server error, not a silent null)."""
262
+ var_defs = ", ".join(f"${k}: {arg_gql_types[k]}" for k in provided)
263
+ call_args = ", ".join(f"{k}: ${k}" for k in provided)
264
+ head = f"query({var_defs})" if var_defs else "query"
265
+ call = f"{field_name}({call_args})" if call_args else field_name
266
+ return f"{head} {{ {call} {selection} }}".replace(" ", " ").strip()
267
+
268
+
269
+ def _make_executor(endpoint: str | None, field_name: str, selection: str,
270
+ arg_gql_types: dict, auth):
271
+ def fn(args: dict, timeout: int, list_limit: int | None = None) -> dict:
272
+ n = list_limit or 50 # how many items of a list response to keep (per-query override)
273
+ if not endpoint:
274
+ return {"ok": False, "content": "", "source": "",
275
+ "error": "No GraphQL endpoint configured for this schema — add the server URL "
276
+ "so I can run queries (the schema only documents the operations)."}
277
+ provided = {k: v for k, v in (args or {}).items()
278
+ if v is not None and k in arg_gql_types}
279
+ query = _build_query(field_name, selection, arg_gql_types, provided)
280
+ headers = {**_UA, "Content-Type": "application/json"}
281
+ if auth:
282
+ headers[auth[0]] = auth[1]
283
+ try:
284
+ resp = requests.post(endpoint, json={"query": query, "variables": provided},
285
+ headers=headers, timeout=timeout)
286
+ except Exception as e:
287
+ return {"ok": False, "error": str(e), "content": "", "source": endpoint}
288
+ if resp.status_code >= 400:
289
+ return {"ok": False, "error": f"HTTP {resp.status_code}",
290
+ "content": resp.text[:500], "source": endpoint}
291
+ try:
292
+ payload = resp.json()
293
+ except Exception:
294
+ return {"ok": False, "error": "Non-JSON response from GraphQL endpoint",
295
+ "content": _redact_text(resp.text[:500]), "source": endpoint}
296
+ errors = payload.get("errors")
297
+ data = payload.get("data")
298
+ result = data.get(field_name) if isinstance(data, dict) else None
299
+ # Surface the error only when nothing usable came back; if data is present (even partial),
300
+ # prefer returning it. Map auth-ish GraphQL errors to 401 for the agent's re-auth handling.
301
+ if errors and result is None and not data:
302
+ msg = "; ".join(e.get("message", "") for e in errors[:3] if isinstance(e, dict))
303
+ if any(w in msg.lower() for w in _AUTH_WORDS):
304
+ return {"ok": False, "error": f"HTTP 401 — {msg[:200]}", "content": "", "source": endpoint}
305
+ return {"ok": False, "error": f"GraphQL error: {msg[:300]}", "content": "", "source": endpoint}
306
+ content = _compact_json(result if result is not None else data, max_items=n)
307
+ return {"ok": True, "content": content[: max(8000, n * 600)], "source": endpoint}
308
+
309
+ return fn