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.
- schemarouter/__init__.py +88 -0
- schemarouter/_version.py +8 -0
- schemarouter/adapters/__init__.py +29 -0
- schemarouter/adapters/base.py +71 -0
- schemarouter/adapters/mcp.py +179 -0
- schemarouter/adapters/openapi.py +418 -0
- schemarouter/adapters/optimade.py +656 -0
- schemarouter/adapters/python.py +188 -0
- schemarouter/analyzers/__init__.py +3 -0
- schemarouter/analyzers/model.py +185 -0
- schemarouter/errors.py +50 -0
- schemarouter/executor.py +207 -0
- schemarouter/ingestion.py +348 -0
- schemarouter/integrations/__init__.py +3 -0
- schemarouter/integrations/langchain.py +96 -0
- schemarouter/models.py +154 -0
- schemarouter/planner.py +253 -0
- schemarouter/policy.py +51 -0
- schemarouter/proposals.py +391 -0
- schemarouter/py.typed +0 -0
- schemarouter/registry.py +84 -0
- schemarouter/runs.py +77 -0
- schemarouter/runtime.py +686 -0
- schemarouter/validation.py +96 -0
- schemarouter-0.2.0a1.dist-info/METADATA +297 -0
- schemarouter-0.2.0a1.dist-info/RECORD +28 -0
- schemarouter-0.2.0a1.dist-info/WHEEL +4 -0
- schemarouter-0.2.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -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}
|