schemarouter 0.2.0a1__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,418 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from copy import deepcopy
5
+ from typing import Any
6
+ from urllib.parse import quote, unquote, urljoin, urlparse
7
+
8
+ import httpx
9
+
10
+ from ..models import EndpointSpec, FieldSpec, ParameterSpec, ToolSpec
11
+
12
+ _HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head", "trace"}
13
+ _SENSITIVE_RUNTIME_HEADERS = {
14
+ "authorization",
15
+ "connection",
16
+ "content-length",
17
+ "cookie",
18
+ "host",
19
+ "proxy-authorization",
20
+ "transfer-encoding",
21
+ "upgrade",
22
+ }
23
+ _HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_\x60|~0-9A-Za-z-]+$")
24
+
25
+
26
+ def _resolve_local_ref(document: dict[str, Any], value: Any) -> Any:
27
+ if not isinstance(value, dict) or "$ref" not in value:
28
+ return value
29
+ ref = value["$ref"]
30
+ if not isinstance(ref, str) or not ref.startswith("#/"):
31
+ return value
32
+ node: Any = document
33
+ for part in ref[2:].split("/"):
34
+ part = part.replace("~1", "/").replace("~0", "~")
35
+ node = node[part]
36
+ return deepcopy(node)
37
+
38
+
39
+ def _schema_properties(document: dict[str, Any], schema: Any) -> dict[str, dict[str, Any]]:
40
+ schema = _resolve_local_ref(document, schema)
41
+ if not isinstance(schema, dict):
42
+ return {}
43
+ props = schema.get("properties", {})
44
+ if not isinstance(props, dict):
45
+ return {}
46
+ return {name: _resolve_local_ref(document, spec) for name, spec in props.items()}
47
+
48
+
49
+ def _with_components(
50
+ document: dict[str, Any],
51
+ schema: dict[str, Any],
52
+ ) -> dict[str, Any]:
53
+ resolved = deepcopy(schema)
54
+ components = document.get("components")
55
+ if isinstance(components, dict) and components:
56
+ resolved["components"] = deepcopy(components)
57
+ return resolved
58
+
59
+
60
+ def _disambiguate_parameter_names(
61
+ parameters: list[ParameterSpec],
62
+ ) -> list[ParameterSpec]:
63
+ counts: dict[str, int] = {}
64
+ for parameter in parameters:
65
+ counts[parameter.name] = counts.get(parameter.name, 0) + 1
66
+
67
+ used: set[str] = set()
68
+ result: list[ParameterSpec] = []
69
+ for parameter in parameters:
70
+ raw_name = parameter.name
71
+ logical_name = raw_name
72
+ if counts[raw_name] > 1 or logical_name in used:
73
+ base = f"{parameter.location}__{raw_name}"
74
+ logical_name = base
75
+ suffix = 2
76
+ while logical_name in used:
77
+ logical_name = f"{base}__{suffix}"
78
+ suffix += 1
79
+
80
+ used.add(logical_name)
81
+ if logical_name == raw_name:
82
+ result.append(parameter)
83
+ else:
84
+ result.append(
85
+ parameter.model_copy(
86
+ update={
87
+ "name": logical_name,
88
+ "wire_name": parameter.wire_name or raw_name,
89
+ },
90
+ deep=True,
91
+ )
92
+ )
93
+ return result
94
+
95
+
96
+ def _parameters_schema(
97
+ document: dict[str, Any],
98
+ parameters: list[ParameterSpec],
99
+ ) -> dict[str, Any]:
100
+ properties = {
101
+ parameter.name: parameter.json_schema or {}
102
+ for parameter in parameters
103
+ }
104
+ required = [
105
+ parameter.name
106
+ for parameter in parameters
107
+ if parameter.required
108
+ ]
109
+ schema: dict[str, Any] = {
110
+ "type": "object",
111
+ "properties": properties,
112
+ "additionalProperties": False,
113
+ }
114
+ if required:
115
+ schema["required"] = required
116
+ return _with_components(document, schema)
117
+
118
+
119
+ def _response_schema(document: dict[str, Any], responses: dict[str, Any]) -> dict[str, Any]:
120
+ for code in sorted(responses, key=str):
121
+ if str(code).startswith("2"):
122
+ response = _resolve_local_ref(document, responses[code])
123
+ content = response.get("content", {}) if isinstance(response, dict) else {}
124
+ for media in ("application/json", "application/problem+json"):
125
+ if media in content and isinstance(content[media], dict):
126
+ schema = _resolve_local_ref(document, content[media].get("schema", {}))
127
+ if isinstance(schema, dict):
128
+ return _with_components(document, schema)
129
+ return {}
130
+ return {}
131
+
132
+
133
+ def _origin(url: str) -> tuple[str, str, int]:
134
+ parsed = urlparse(url)
135
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
136
+ raise ValueError("URL must be an absolute http(s) URL")
137
+ default_port = 443 if parsed.scheme == "https" else 80
138
+ return parsed.scheme.lower(), parsed.hostname.lower(), parsed.port or default_port
139
+
140
+
141
+ def same_origin(left: str, right: str) -> bool:
142
+ try:
143
+ return _origin(left) == _origin(right)
144
+ except ValueError:
145
+ return False
146
+
147
+
148
+ def _validate_endpoint_path(path: str) -> None:
149
+ parsed = urlparse(path)
150
+ if (
151
+ not path.startswith("/")
152
+ or parsed.scheme
153
+ or parsed.netloc
154
+ or parsed.query
155
+ or parsed.fragment
156
+ ):
157
+ raise ValueError("endpoint path must be a relative absolute-path without query/fragment")
158
+ for segment in parsed.path.split("/"):
159
+ if unquote(segment).casefold() in {".", ".."}:
160
+ raise ValueError("endpoint path must not contain dot segments")
161
+
162
+
163
+ def tool_from_openapi(
164
+ name: str,
165
+ document: dict[str, Any],
166
+ *,
167
+ namespace: str | None = None,
168
+ ) -> ToolSpec:
169
+ """Create a ToolSpec from an OpenAPI 3.x document.
170
+
171
+ v0.1 intentionally supports the stable common subset and preserves unsupported constructs
172
+ in metadata instead of guessing their runtime semantics.
173
+ """
174
+ endpoints: list[EndpointSpec] = []
175
+ for path, path_item in document.get("paths", {}).items():
176
+ if not isinstance(path, str) or not isinstance(path_item, dict):
177
+ continue
178
+ try:
179
+ _validate_endpoint_path(path)
180
+ except ValueError:
181
+ continue
182
+
183
+ path_parameters = path_item.get("parameters", [])
184
+ for method, operation in path_item.items():
185
+ if method.lower() not in _HTTP_METHODS or not isinstance(operation, dict):
186
+ continue
187
+ fallback_name = path.strip("/").replace("/", "_") or "root"
188
+ operation_id = operation.get("operationId") or f"{method.lower()}_{fallback_name}"
189
+ parameters: list[ParameterSpec] = []
190
+ merged_parameters = [*path_parameters, *operation.get("parameters", [])]
191
+ seen: set[tuple[str, str]] = set()
192
+ for raw_parameter in merged_parameters:
193
+ parameter = _resolve_local_ref(document, raw_parameter)
194
+ if not isinstance(parameter, dict) or "name" not in parameter:
195
+ continue
196
+ location = parameter.get("in", "query")
197
+ key = (parameter["name"], location)
198
+ if key in seen:
199
+ continue
200
+ seen.add(key)
201
+ if location not in {"path", "query", "header"}:
202
+ continue
203
+ if (
204
+ location == "header"
205
+ and str(parameter["name"]).casefold() in _SENSITIVE_RUNTIME_HEADERS
206
+ ):
207
+ continue
208
+ parameters.append(
209
+ ParameterSpec(
210
+ name=parameter["name"],
211
+ description=parameter.get("description", ""),
212
+ required=bool(parameter.get("required")) or location == "path",
213
+ location=location,
214
+ json_schema=_resolve_local_ref(document, parameter.get("schema", {})),
215
+ )
216
+ )
217
+
218
+ request_body = _resolve_local_ref(document, operation.get("requestBody", {}))
219
+ if isinstance(request_body, dict):
220
+ content = request_body.get("content", {})
221
+ json_body = content.get("application/json", {}) if isinstance(content, dict) else {}
222
+ body_schema = (
223
+ _resolve_local_ref(document, json_body.get("schema", {}))
224
+ if isinstance(json_body, dict)
225
+ else {}
226
+ )
227
+ required_body = (
228
+ set(body_schema.get("required", []))
229
+ if isinstance(body_schema, dict)
230
+ else set()
231
+ )
232
+ for prop_name, prop_schema in _schema_properties(document, body_schema).items():
233
+ parameters.append(
234
+ ParameterSpec(
235
+ name=prop_name,
236
+ description=(
237
+ prop_schema.get("description", "")
238
+ if isinstance(prop_schema, dict)
239
+ else ""
240
+ ),
241
+ required=prop_name in required_body,
242
+ location="body",
243
+ json_schema=prop_schema if isinstance(prop_schema, dict) else {},
244
+ )
245
+ )
246
+
247
+ parameters = _disambiguate_parameter_names(parameters)
248
+
249
+ response_schema = _response_schema(document, operation.get("responses", {}))
250
+ fields = [
251
+ FieldSpec(
252
+ name=field_name,
253
+ description=(
254
+ field_schema.get("description", "")
255
+ if isinstance(field_schema, dict)
256
+ else ""
257
+ ),
258
+ json_schema=field_schema if isinstance(field_schema, dict) else {},
259
+ identifier=field_name in {"id", "uuid", "key"} or field_name.endswith("_id"),
260
+ aliases=[field_name.replace("_", " ")],
261
+ )
262
+ for field_name, field_schema in _schema_properties(
263
+ document, response_schema
264
+ ).items()
265
+ ]
266
+
267
+ endpoints.append(
268
+ EndpointSpec(
269
+ name=operation_id,
270
+ description=operation.get("summary") or operation.get("description", ""),
271
+ parameters=parameters,
272
+ output_fields=fields,
273
+ input_schema=_parameters_schema(document, parameters),
274
+ output_schema=response_schema if isinstance(response_schema, dict) else {},
275
+ method=method.upper(),
276
+ path=path,
277
+ read_only=method.lower() in {"get", "head", "options"},
278
+ destructive=method.lower() == "delete",
279
+ metadata={
280
+ "tags": operation.get("tags", []),
281
+ "security": operation.get("security"),
282
+ "deprecated": bool(operation.get("deprecated", False)),
283
+ },
284
+ )
285
+ )
286
+
287
+ return ToolSpec(
288
+ name=name,
289
+ namespace=namespace,
290
+ description=(document.get("info") or {}).get("description", ""),
291
+ endpoints=endpoints,
292
+ metadata={
293
+ "adapter": "openapi",
294
+ "openapi": document.get("openapi"),
295
+ "title": (document.get("info") or {}).get("title"),
296
+ },
297
+ )
298
+
299
+
300
+ def resolve_openapi_base_url(document: dict[str, Any], source_url: str) -> str:
301
+ servers = document.get("servers") or []
302
+ if servers and isinstance(servers[0], dict) and servers[0].get("url"):
303
+ resolved = urljoin(source_url, str(servers[0]["url"]))
304
+ else:
305
+ resolved = urljoin(source_url, "/")
306
+
307
+ parsed = urlparse(resolved)
308
+ if (
309
+ parsed.scheme not in {"http", "https"}
310
+ or not parsed.netloc
311
+ or parsed.username
312
+ or parsed.password
313
+ or parsed.query
314
+ or parsed.fragment
315
+ ):
316
+ raise ValueError("OpenAPI server URL is not a safe absolute http(s) base URL")
317
+ return resolved
318
+
319
+
320
+ class OpenAPIRemoteInvoker:
321
+ """Minimal trusted HTTP executor for a parsed OpenAPI tool."""
322
+
323
+ def __init__(
324
+ self,
325
+ tool: ToolSpec,
326
+ base_url: str,
327
+ *,
328
+ trusted_headers: dict[str, str] | None = None,
329
+ timeout: float = 20.0,
330
+ ) -> None:
331
+ parsed_base = urlparse(base_url)
332
+ if parsed_base.scheme not in {"http", "https"} or not parsed_base.netloc:
333
+ raise ValueError("base_url must be an absolute http(s) URL")
334
+ if parsed_base.username or parsed_base.password:
335
+ raise ValueError("base_url must not contain credentials")
336
+ if parsed_base.query or parsed_base.fragment:
337
+ raise ValueError("base_url must not contain query or fragment")
338
+
339
+ trusted = dict(trusted_headers or {})
340
+ trusted_names = [name.casefold() for name in trusted]
341
+ if len(trusted_names) != len(set(trusted_names)):
342
+ raise ValueError("trusted_headers contains case-insensitive duplicate names")
343
+ for name in trusted:
344
+ if not _HEADER_NAME_RE.fullmatch(name):
345
+ raise ValueError(f"invalid trusted header name: {name!r}")
346
+
347
+ self.tool = tool
348
+ self.base_url = base_url.rstrip("/")
349
+ self.approved_origin = _origin(base_url)
350
+ self.trusted_headers = trusted
351
+ self.trusted_header_names = set(trusted_names)
352
+ self.timeout = timeout
353
+
354
+ async def __call__(self, endpoint_name: str, arguments: dict[str, Any]) -> Any:
355
+ endpoint = self.tool.endpoint(endpoint_name)
356
+ if not endpoint.method or not endpoint.path:
357
+ raise RuntimeError(f"endpoint {endpoint_name!r} is missing HTTP method/path")
358
+
359
+ try:
360
+ _validate_endpoint_path(endpoint.path)
361
+ except ValueError as exc:
362
+ raise RuntimeError(f"unsafe endpoint path for {endpoint_name!r}") from exc
363
+
364
+ path = endpoint.path
365
+ query: dict[str, Any] = {}
366
+ body: dict[str, Any] = {}
367
+ headers: dict[str, str] = {}
368
+
369
+ for parameter in endpoint.parameters:
370
+ if parameter.name not in arguments:
371
+ continue
372
+ value = arguments[parameter.name]
373
+ wire_name = parameter.wire_name or parameter.name
374
+ if parameter.location == "path":
375
+ encoded = quote(str(value), safe="").replace(".", "%2E")
376
+ path = path.replace("{" + wire_name + "}", encoded)
377
+ elif parameter.location == "query":
378
+ query[wire_name] = value
379
+ elif parameter.location == "header":
380
+ normalized_name = wire_name.casefold()
381
+ if not _HEADER_NAME_RE.fullmatch(wire_name):
382
+ raise RuntimeError(f"invalid header parameter name: {wire_name!r}")
383
+ if normalized_name in self.trusted_header_names:
384
+ raise RuntimeError(
385
+ f"tool argument cannot override trusted header {wire_name!r}"
386
+ )
387
+ if normalized_name in _SENSITIVE_RUNTIME_HEADERS:
388
+ raise RuntimeError(
389
+ f"sensitive header {wire_name!r} must come from trusted runtime auth"
390
+ )
391
+ headers[wire_name] = str(value)
392
+ elif parameter.location == "body":
393
+ body[wire_name] = value
394
+
395
+ if re.search(r"{[^{}]+}", path):
396
+ raise RuntimeError(f"unresolved path parameter in endpoint {endpoint_name!r}")
397
+
398
+ headers.update(self.trusted_headers)
399
+
400
+ # Concatenation is intentional: urljoin would normalize dot-segments or allow an
401
+ # absolute path to replace the approved server path prefix.
402
+ url = self.base_url + "/" + path.lstrip("/")
403
+ if _origin(url) != self.approved_origin:
404
+ raise RuntimeError("endpoint path escaped the approved API origin")
405
+
406
+ async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=False) as client:
407
+ response = await client.request(
408
+ endpoint.method,
409
+ url,
410
+ params=query or None,
411
+ json=body or None,
412
+ headers=headers or None,
413
+ )
414
+ response.raise_for_status()
415
+ content_type = response.headers.get("content-type", "").lower()
416
+ if "json" in content_type:
417
+ return response.json()
418
+ return {"text": response.text}