xyberos-http-api 0.1.0__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.
- xyberos_http_api/__init__.py +30 -0
- xyberos_http_api/auth.py +136 -0
- xyberos_http_api/builder.py +138 -0
- xyberos_http_api/client.py +126 -0
- xyberos_http_api/errors.py +19 -0
- xyberos_http_api/plugin.py +134 -0
- xyberos_http_api/spec.py +241 -0
- xyberos_http_api-0.1.0.dist-info/METADATA +122 -0
- xyberos_http_api-0.1.0.dist-info/RECORD +12 -0
- xyberos_http_api-0.1.0.dist-info/WHEEL +5 -0
- xyberos_http_api-0.1.0.dist-info/entry_points.txt +2 -0
- xyberos_http_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Generic HTTP/API connector plugin (RFC-0019, M2).
|
|
2
|
+
|
|
3
|
+
"Point at any REST API, get typed tools." A declarative spec (JSON / YAML /
|
|
4
|
+
Python ``dict``) describes a ``base_url``, optional auth, optional rate
|
|
5
|
+
limiting, and one *operation* per endpoint. Each operation becomes a typed
|
|
6
|
+
:class:`~xyberos.contracts.Tool` whose parameters are validated and coerced
|
|
7
|
+
through :class:`~xyberos.tools.FunctionTool`.
|
|
8
|
+
|
|
9
|
+
Everything here builds on the public ``xyberos`` API only — no runtime
|
|
10
|
+
dependencies beyond the standard library.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .builder import build_operation_tool
|
|
14
|
+
from .client import HttpClient
|
|
15
|
+
from .errors import HttpApiError
|
|
16
|
+
from .plugin import HttpApiPlugin
|
|
17
|
+
from .spec import AuthSpec, HttpApiSpec, Operation, Param, RateLimitSpec, load_spec
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AuthSpec",
|
|
21
|
+
"HttpApiError",
|
|
22
|
+
"HttpApiPlugin",
|
|
23
|
+
"HttpApiSpec",
|
|
24
|
+
"HttpClient",
|
|
25
|
+
"Operation",
|
|
26
|
+
"Param",
|
|
27
|
+
"RateLimitSpec",
|
|
28
|
+
"build_operation_tool",
|
|
29
|
+
"load_spec",
|
|
30
|
+
]
|
xyberos_http_api/auth.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Authentication strategies for the HTTP/API connector.
|
|
2
|
+
|
|
3
|
+
Secrets are resolved from environment variables first (``*_env`` fields), then
|
|
4
|
+
fall back to literal values. ``api_key``, ``bearer`` and ``basic`` are
|
|
5
|
+
stateless; ``oauth2`` (client_credentials) fetches a token from ``token_url``
|
|
6
|
+
and caches it for reuse.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .errors import HttpApiError
|
|
22
|
+
from .spec import AuthSpec
|
|
23
|
+
|
|
24
|
+
#: Injectable ``urlopen``-shaped transport for tests (no network needed).
|
|
25
|
+
UrlOpen = Callable[..., Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_urlopen(request: Any, timeout: float) -> Any:
|
|
29
|
+
return urllib.request.urlopen(request, timeout=timeout)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ResolvedAuth:
|
|
34
|
+
"""Headers and query params produced by resolving an auth strategy."""
|
|
35
|
+
|
|
36
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
query: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthResolver:
|
|
41
|
+
"""Resolves an :class:`AuthSpec` into concrete headers/query params."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
auth: AuthSpec,
|
|
46
|
+
*,
|
|
47
|
+
urlopen: UrlOpen | None = None,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._auth = auth
|
|
51
|
+
self._urlopen = urlopen or _default_urlopen
|
|
52
|
+
self._timeout = timeout
|
|
53
|
+
self._token: str | None = None
|
|
54
|
+
self._token_url: str | None = None
|
|
55
|
+
|
|
56
|
+
def resolve(self) -> ResolvedAuth:
|
|
57
|
+
"""Return the auth headers/query for the current request."""
|
|
58
|
+
kind = self._auth.type or "none"
|
|
59
|
+
if kind == "none":
|
|
60
|
+
return ResolvedAuth()
|
|
61
|
+
if kind == "api_key":
|
|
62
|
+
return self._api_key()
|
|
63
|
+
if kind == "bearer":
|
|
64
|
+
return self._bearer()
|
|
65
|
+
if kind == "basic":
|
|
66
|
+
return self._basic()
|
|
67
|
+
if kind == "oauth2":
|
|
68
|
+
return self._oauth2()
|
|
69
|
+
raise HttpApiError(status=None, body=f"unsupported auth type: {kind}")
|
|
70
|
+
|
|
71
|
+
# -- helpers ------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _secret(literal: str | None, env: str | None) -> str | None:
|
|
75
|
+
if env:
|
|
76
|
+
value = os.getenv(env)
|
|
77
|
+
if value is not None:
|
|
78
|
+
return value
|
|
79
|
+
return literal
|
|
80
|
+
|
|
81
|
+
def _api_key(self) -> ResolvedAuth:
|
|
82
|
+
value = self._secret(self._auth.value, self._auth.env)
|
|
83
|
+
if not value:
|
|
84
|
+
return ResolvedAuth()
|
|
85
|
+
if self._auth.key_in == "query":
|
|
86
|
+
return ResolvedAuth(query={self._auth.key_name: value})
|
|
87
|
+
return ResolvedAuth(headers={self._auth.key_name: value})
|
|
88
|
+
|
|
89
|
+
def _bearer(self) -> ResolvedAuth:
|
|
90
|
+
token = self._secret(self._auth.token, self._auth.token_env)
|
|
91
|
+
if not token:
|
|
92
|
+
return ResolvedAuth()
|
|
93
|
+
return ResolvedAuth(headers={"Authorization": f"Bearer {token}"})
|
|
94
|
+
|
|
95
|
+
def _basic(self) -> ResolvedAuth:
|
|
96
|
+
username = self._secret(self._auth.username, self._auth.username_env) or ""
|
|
97
|
+
password = self._secret(self._auth.password, self._auth.password_env) or ""
|
|
98
|
+
raw = f"{username}:{password}".encode("utf-8")
|
|
99
|
+
encoded = base64.b64encode(raw).decode("ascii")
|
|
100
|
+
return ResolvedAuth(headers={"Authorization": f"Basic {encoded}"})
|
|
101
|
+
|
|
102
|
+
def _oauth2(self) -> ResolvedAuth:
|
|
103
|
+
auth = self._auth
|
|
104
|
+
token_url = auth.token_url
|
|
105
|
+
if not token_url:
|
|
106
|
+
raise HttpApiError(status=None, body="oauth2 auth requires a 'token_url'")
|
|
107
|
+
if self._token and self._token_url == token_url:
|
|
108
|
+
return ResolvedAuth(headers={"Authorization": f"Bearer {self._token}"})
|
|
109
|
+
client_id = self._secret(auth.client_id, auth.client_id_env) or ""
|
|
110
|
+
client_secret = self._secret(auth.client_secret, auth.client_secret_env) or ""
|
|
111
|
+
form = {
|
|
112
|
+
"grant_type": "client_credentials",
|
|
113
|
+
"client_id": client_id,
|
|
114
|
+
"client_secret": client_secret,
|
|
115
|
+
}
|
|
116
|
+
if auth.scope:
|
|
117
|
+
form["scope"] = auth.scope
|
|
118
|
+
data = urllib.parse.urlencode(form).encode("utf-8")
|
|
119
|
+
request = urllib.request.Request(
|
|
120
|
+
token_url,
|
|
121
|
+
data=data,
|
|
122
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
123
|
+
method="POST",
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
with self._urlopen(request, self._timeout) as response:
|
|
127
|
+
raw = response.read()
|
|
128
|
+
except urllib.error.HTTPError as exc:
|
|
129
|
+
raise HttpApiError(exc.code, exc.read().decode("utf-8", errors="replace")) from exc
|
|
130
|
+
payload = json.loads(raw.decode("utf-8", errors="replace"))
|
|
131
|
+
token = payload.get("access_token")
|
|
132
|
+
if not token:
|
|
133
|
+
raise HttpApiError(status=None, body="oauth2 token response had no access_token")
|
|
134
|
+
self._token = str(token)
|
|
135
|
+
self._token_url = token_url
|
|
136
|
+
return ResolvedAuth(headers={"Authorization": f"Bearer {self._token}"})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Turn declared operations into typed :class:`~xyberos.contracts.Tool`s.
|
|
2
|
+
|
|
3
|
+
Each operation becomes a :class:`~xyberos.tools.FunctionTool` whose signature
|
|
4
|
+
is derived from the declared parameters. ``FunctionTool`` then handles JSON
|
|
5
|
+
schema generation, argument validation, and coercion through
|
|
6
|
+
``coerce_arguments`` — so an LLM (or a user) passing ``"10.5"`` for a ``number``
|
|
7
|
+
parameter gets it coerced to ``10.5``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from xyberos.contracts import Tool
|
|
17
|
+
from xyberos.tools import FunctionTool
|
|
18
|
+
|
|
19
|
+
from .client import HttpClient
|
|
20
|
+
from .spec import HttpApiSpec, Operation, Param
|
|
21
|
+
|
|
22
|
+
_TOKEN_RE = re.compile(r"\[(\d+)\]")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _safe_identifier(name: str) -> str:
|
|
26
|
+
"""Turn an arbitrary param/header name into a valid Python identifier."""
|
|
27
|
+
ident = re.sub(r"\W", "_", name)
|
|
28
|
+
if not ident:
|
|
29
|
+
ident = "param"
|
|
30
|
+
if ident[0].isdigit():
|
|
31
|
+
ident = "_" + ident
|
|
32
|
+
return ident
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_typed_callable(name: str, params: list[Param], impl: Any) -> Any:
|
|
36
|
+
"""Build a callable whose ``__signature__`` matches the declared params.
|
|
37
|
+
|
|
38
|
+
``impl(kwargs)`` receives the validated/coerced arguments as a dict keyed
|
|
39
|
+
by the **declared** param names. ``inspect.signature`` honors
|
|
40
|
+
``__signature__``, so both :func:`~xyberos.tools.build_json_schema` and
|
|
41
|
+
:func:`~xyberos.tools.coerce_arguments` see the declared, typed signature.
|
|
42
|
+
Parameter names that are not valid Python identifiers (e.g. header names
|
|
43
|
+
like ``X-Trace``) are sanitized for the signature and mapped back.
|
|
44
|
+
"""
|
|
45
|
+
mapping: dict[str, str] = {}
|
|
46
|
+
signature_params: list[inspect.Parameter] = []
|
|
47
|
+
for param in params:
|
|
48
|
+
sig_name = param.name if param.name.isidentifier() else _safe_identifier(param.name)
|
|
49
|
+
mapping[sig_name] = param.name
|
|
50
|
+
if param.required:
|
|
51
|
+
signature_params.append(
|
|
52
|
+
inspect.Parameter(
|
|
53
|
+
sig_name,
|
|
54
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
55
|
+
annotation=param.python_type,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
signature_params.append(
|
|
60
|
+
inspect.Parameter(
|
|
61
|
+
sig_name,
|
|
62
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
63
|
+
default=param.default,
|
|
64
|
+
annotation=param.python_type,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
signature = inspect.Signature(parameters=signature_params)
|
|
68
|
+
|
|
69
|
+
def _call(**kwargs: Any) -> Any:
|
|
70
|
+
real = {mapping.get(key, key): value for key, value in kwargs.items()}
|
|
71
|
+
return impl(real)
|
|
72
|
+
|
|
73
|
+
_call.__name__ = name
|
|
74
|
+
_call.__qualname__ = name
|
|
75
|
+
_call.__signature__ = signature
|
|
76
|
+
return _call
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_path(data: Any, path: str | None) -> Any:
|
|
80
|
+
"""Extract a dotted path (with optional ``[index]`` steps) from JSON data."""
|
|
81
|
+
if not path:
|
|
82
|
+
return data
|
|
83
|
+
current = data
|
|
84
|
+
for part in path.split("."):
|
|
85
|
+
match = _TOKEN_RE.search(part)
|
|
86
|
+
key = _TOKEN_RE.sub("", part) if match else part
|
|
87
|
+
if key and isinstance(current, dict):
|
|
88
|
+
current = current.get(key)
|
|
89
|
+
elif key and not isinstance(current, dict):
|
|
90
|
+
return None
|
|
91
|
+
if match and isinstance(current, list):
|
|
92
|
+
try:
|
|
93
|
+
current = current[int(match.group(1))]
|
|
94
|
+
except (IndexError, ValueError):
|
|
95
|
+
return None
|
|
96
|
+
return current
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_operation_tool(
|
|
100
|
+
spec: HttpApiSpec,
|
|
101
|
+
operation: Operation,
|
|
102
|
+
client: HttpClient,
|
|
103
|
+
*,
|
|
104
|
+
prefix: str = "",
|
|
105
|
+
) -> Tool:
|
|
106
|
+
"""Build one typed tool for ``operation`` backed by ``client``."""
|
|
107
|
+
|
|
108
|
+
def _impl(arguments: dict[str, Any]) -> Any:
|
|
109
|
+
path = operation.path
|
|
110
|
+
query: dict[str, Any] = {}
|
|
111
|
+
body: Any = None
|
|
112
|
+
extra_headers: dict[str, str] = {}
|
|
113
|
+
for param in operation.params:
|
|
114
|
+
if param.name not in arguments:
|
|
115
|
+
continue
|
|
116
|
+
value = arguments[param.name]
|
|
117
|
+
if param.in_ == "path":
|
|
118
|
+
path = path.replace("{" + param.name + "}", str(value))
|
|
119
|
+
elif param.in_ == "query":
|
|
120
|
+
query[param.name] = value
|
|
121
|
+
elif param.in_ == "header":
|
|
122
|
+
extra_headers[param.name] = str(value)
|
|
123
|
+
elif param.in_ == "body":
|
|
124
|
+
body = value
|
|
125
|
+
result = client.request(
|
|
126
|
+
operation.method,
|
|
127
|
+
path,
|
|
128
|
+
query=query,
|
|
129
|
+
body=body,
|
|
130
|
+
headers=extra_headers,
|
|
131
|
+
)
|
|
132
|
+
if operation.response_format == "text":
|
|
133
|
+
return result
|
|
134
|
+
return extract_path(result, operation.response_path)
|
|
135
|
+
|
|
136
|
+
name = f"{prefix}{operation.name}" if prefix else operation.name
|
|
137
|
+
callable_func = make_typed_callable(name, list(operation.params), _impl)
|
|
138
|
+
return FunctionTool(name, callable_func, description=operation.description)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Minimal dependency-free HTTP client used by generated operation tools.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library (``urllib``), applies the declared auth, merges
|
|
4
|
+
the declared headers, and optionally throttles calls with the core's
|
|
5
|
+
:class:`~xyberos.utils.resilience.RateLimiter`. The ``urlopen`` transport is
|
|
6
|
+
injectable so tests can run without a network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from collections.abc import Callable, Mapping
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from xyberos.utils.resilience import RateLimiter
|
|
19
|
+
|
|
20
|
+
from .auth import AuthResolver, ResolvedAuth, UrlOpen
|
|
21
|
+
from .errors import HttpApiError
|
|
22
|
+
from .spec import AuthSpec, RateLimitSpec
|
|
23
|
+
|
|
24
|
+
#: Injectable ``urlopen``-shaped transport (defaults to the stdlib opener).
|
|
25
|
+
UrlOpenCallable = Callable[..., Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_urlopen(request: Any, timeout: float) -> Any:
|
|
29
|
+
return urllib.request.urlopen(request, timeout=timeout)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HttpClient:
|
|
33
|
+
"""A tiny typed-over-JSON HTTP client for one API base URL."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
base_url: str,
|
|
38
|
+
*,
|
|
39
|
+
headers: Mapping[str, str] | None = None,
|
|
40
|
+
auth: AuthSpec | None = None,
|
|
41
|
+
rate_limit: RateLimitSpec | None = None,
|
|
42
|
+
timeout: float = 30.0,
|
|
43
|
+
urlopen: UrlOpen | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._base_url = str(base_url).rstrip("/")
|
|
46
|
+
self._headers = dict(headers or {})
|
|
47
|
+
self._auth_resolver = AuthResolver(auth, urlopen=urlopen, timeout=timeout) if auth else None
|
|
48
|
+
self._timeout = timeout
|
|
49
|
+
self._urlopen = urlopen or _default_urlopen
|
|
50
|
+
self._limiter: RateLimiter | None = None
|
|
51
|
+
if rate_limit is not None:
|
|
52
|
+
self._limiter = RateLimiter(
|
|
53
|
+
calls_per_second=rate_limit.calls_per_second,
|
|
54
|
+
burst=rate_limit.burst,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def base_url(self) -> str:
|
|
59
|
+
return self._base_url
|
|
60
|
+
|
|
61
|
+
def request(
|
|
62
|
+
self,
|
|
63
|
+
method: str,
|
|
64
|
+
path: str,
|
|
65
|
+
*,
|
|
66
|
+
query: Mapping[str, Any] | None = None,
|
|
67
|
+
body: Any = None,
|
|
68
|
+
headers: Mapping[str, str] | None = None,
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Send one request and return the decoded body (JSON dict/list or text)."""
|
|
71
|
+
resolved = self._auth_resolver.resolve() if self._auth_resolver else ResolvedAuth()
|
|
72
|
+
|
|
73
|
+
url = self._base_url + (path or "/")
|
|
74
|
+
query_params = dict(resolved.query)
|
|
75
|
+
query_params.update({k: v for k, v in (query or {}).items() if v is not None})
|
|
76
|
+
if query_params:
|
|
77
|
+
separator = "&" if "?" in url else "?"
|
|
78
|
+
url += separator + urllib.parse.urlencode(query_params)
|
|
79
|
+
|
|
80
|
+
request_headers = dict(self._headers)
|
|
81
|
+
request_headers.update(resolved.headers)
|
|
82
|
+
request_headers.update(headers or {})
|
|
83
|
+
|
|
84
|
+
data: bytes | None = None
|
|
85
|
+
if body is not None:
|
|
86
|
+
data = json.dumps(body).encode("utf-8")
|
|
87
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
88
|
+
|
|
89
|
+
if self._limiter is not None:
|
|
90
|
+
self._limiter.acquire()
|
|
91
|
+
|
|
92
|
+
request = urllib.request.Request(url, data=data, headers=request_headers, method=method)
|
|
93
|
+
try:
|
|
94
|
+
with self._urlopen(request, self._timeout) as response:
|
|
95
|
+
status = int(getattr(response, "status", 200))
|
|
96
|
+
raw = response.read()
|
|
97
|
+
except urllib.error.HTTPError as exc:
|
|
98
|
+
status = exc.code
|
|
99
|
+
raw = exc.read()
|
|
100
|
+
|
|
101
|
+
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
102
|
+
if not 200 <= status < 300:
|
|
103
|
+
raise HttpApiError(status, text)
|
|
104
|
+
if not text:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
return json.loads(text)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
# -- convenience verbs --------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
114
|
+
return self.request("GET", path, **kwargs)
|
|
115
|
+
|
|
116
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
117
|
+
return self.request("POST", path, **kwargs)
|
|
118
|
+
|
|
119
|
+
def put(self, path: str, **kwargs: Any) -> Any:
|
|
120
|
+
return self.request("PUT", path, **kwargs)
|
|
121
|
+
|
|
122
|
+
def patch(self, path: str, **kwargs: Any) -> Any:
|
|
123
|
+
return self.request("PATCH", path, **kwargs)
|
|
124
|
+
|
|
125
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
126
|
+
return self.request("DELETE", path, **kwargs)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Errors raised by the HTTP/API connector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpApiError(Exception):
|
|
7
|
+
"""Raised when an API request fails (non-2xx status or transport error).
|
|
8
|
+
|
|
9
|
+
``status`` is the HTTP status code (or ``None`` for transport errors) and
|
|
10
|
+
``body`` carries the raw response text when one was returned.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status: int | None = None, body: str = "") -> None:
|
|
14
|
+
self.status = status
|
|
15
|
+
self.body = body
|
|
16
|
+
message = f"HTTP {status}" if status is not None else "transport error"
|
|
17
|
+
if body:
|
|
18
|
+
message += f": {body[:200]}"
|
|
19
|
+
super().__init__(message)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Generic HTTP/API connector plugin (RFC-0019, M2).
|
|
2
|
+
|
|
3
|
+
Ships as a plugin using only the public ``xyberos`` API — the ``Plugin``
|
|
4
|
+
contract plus ``Tool`` / ``FunctionTool``. A declarative spec (passed directly,
|
|
5
|
+
or loaded from ``HTTP_API_SPEC`` / ``HTTP_API_SPEC_JSON``) yields one typed
|
|
6
|
+
:class:`~xyberos.contracts.Tool` per declared operation.
|
|
7
|
+
|
|
8
|
+
The module-level ``plugin`` instance is safe to auto-discover via the
|
|
9
|
+
``xyberos.plugins`` entry-point group: when no spec is configured it registers
|
|
10
|
+
nothing and logs a warning instead of raising.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
20
|
+
from xyberos.contracts import Plugin, Tool
|
|
21
|
+
|
|
22
|
+
from .builder import build_operation_tool
|
|
23
|
+
from .client import HttpClient
|
|
24
|
+
from .spec import HttpApiSpec, load_spec
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pop_tool(registry: Any, name: str) -> None:
|
|
28
|
+
"""Best-effort removal of a tool (ToolRegistry has no public unregister)."""
|
|
29
|
+
unregister = getattr(registry, "unregister", None)
|
|
30
|
+
if callable(unregister):
|
|
31
|
+
unregister(name)
|
|
32
|
+
return
|
|
33
|
+
store = getattr(registry, "_tools", None)
|
|
34
|
+
if isinstance(store, dict):
|
|
35
|
+
cast(dict[str, Any], store).pop(name, None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_specs(spec: Any) -> list[HttpApiSpec]:
|
|
39
|
+
loaded = load_spec(spec)
|
|
40
|
+
return loaded if isinstance(loaded, list) else [loaded]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HttpApiPlugin(Plugin):
|
|
44
|
+
"""Registers one typed :class:`Tool` per declared operation."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, spec: Any = None, *, env_prefix: str = "HTTP_API") -> None:
|
|
47
|
+
# Spec resolution is deferred so the module-level instance can be
|
|
48
|
+
# auto-discovered even before any configuration exists.
|
|
49
|
+
self._spec_arg = spec
|
|
50
|
+
self._env_prefix = env_prefix
|
|
51
|
+
self._specs: list[HttpApiSpec] | None = None
|
|
52
|
+
self._tools: list[Tool] | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def name(self) -> str:
|
|
56
|
+
return "http_api"
|
|
57
|
+
|
|
58
|
+
# -- public API ---------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def tools(self) -> list[Tool]:
|
|
61
|
+
"""The generated tools; raises if no spec has been configured."""
|
|
62
|
+
if self._tools is None:
|
|
63
|
+
specs = self._resolve_specs()
|
|
64
|
+
built: list[Tool] = []
|
|
65
|
+
multiple = len(specs) > 1
|
|
66
|
+
for spec in specs:
|
|
67
|
+
prefix = f"{spec.name}_" if multiple else ""
|
|
68
|
+
client = HttpClient(
|
|
69
|
+
spec.base_url,
|
|
70
|
+
headers=spec.headers,
|
|
71
|
+
auth=spec.auth if spec.auth.type != "none" else None,
|
|
72
|
+
rate_limit=spec.rate_limit,
|
|
73
|
+
timeout=spec.timeout,
|
|
74
|
+
)
|
|
75
|
+
for operation in spec.operations:
|
|
76
|
+
built.append(build_operation_tool(spec, operation, client, prefix=prefix))
|
|
77
|
+
self._tools = built
|
|
78
|
+
return self._tools
|
|
79
|
+
|
|
80
|
+
def register(self, kernel: object) -> None:
|
|
81
|
+
try:
|
|
82
|
+
tools = self.tools()
|
|
83
|
+
except ValueError as exc:
|
|
84
|
+
logger = getattr(kernel, "logger", None)
|
|
85
|
+
if logger is not None and callable(getattr(logger, "warning", None)):
|
|
86
|
+
logger.warning("http_api plugin not configured: %s", exc)
|
|
87
|
+
return
|
|
88
|
+
registry = kernel.resolve("tools")
|
|
89
|
+
for tool in tools:
|
|
90
|
+
registry.register(tool)
|
|
91
|
+
|
|
92
|
+
def unregister(self, kernel: object) -> None:
|
|
93
|
+
if self._spec_arg is None and self._specs is None and not self._configured_via_env():
|
|
94
|
+
return
|
|
95
|
+
registry = kernel.resolve("tools")
|
|
96
|
+
for tool in self.tools():
|
|
97
|
+
_pop_tool(registry, tool.name)
|
|
98
|
+
|
|
99
|
+
def _configured_via_env(self) -> bool:
|
|
100
|
+
prefix = self._env_prefix
|
|
101
|
+
return bool(os.getenv(f"{prefix}_SPEC") or os.getenv(f"{prefix}_SPEC_JSON"))
|
|
102
|
+
|
|
103
|
+
# -- internals ----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _resolve_specs(self) -> list[HttpApiSpec]:
|
|
106
|
+
if self._specs is None:
|
|
107
|
+
if self._spec_arg is not None:
|
|
108
|
+
self._specs = _normalize_specs(self._spec_arg)
|
|
109
|
+
else:
|
|
110
|
+
self._specs = _normalize_specs(self._spec_from_env())
|
|
111
|
+
return self._specs
|
|
112
|
+
|
|
113
|
+
def _spec_from_env(self) -> Any:
|
|
114
|
+
prefix = self._env_prefix
|
|
115
|
+
spec_json = os.getenv(f"{prefix}_SPEC_JSON")
|
|
116
|
+
if spec_json:
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(spec_json)
|
|
119
|
+
except json.JSONDecodeError as exc:
|
|
120
|
+
raise ValueError(f"{prefix}_SPEC_JSON is not valid JSON: {exc}") from exc
|
|
121
|
+
spec_path = os.getenv(f"{prefix}_SPEC")
|
|
122
|
+
if spec_path:
|
|
123
|
+
path = Path(spec_path)
|
|
124
|
+
if not path.is_file():
|
|
125
|
+
raise ValueError(f"{prefix}_SPEC points to a missing file: {path}")
|
|
126
|
+
return path
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"http_api plugin is not configured: pass spec=... or set "
|
|
129
|
+
f"{prefix}_SPEC (path to a JSON/YAML file) or {prefix}_SPEC_JSON"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
#: Auto-discovered by ``app.load_entry_points()``.
|
|
134
|
+
plugin = HttpApiPlugin()
|
xyberos_http_api/spec.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Declarative REST API specification for the HTTP/API connector.
|
|
2
|
+
|
|
3
|
+
A spec is authored as a Python ``dict``, a JSON file, or a YAML file (the YAML
|
|
4
|
+
dependency is imported lazily). It describes a ``base_url``, optional headers,
|
|
5
|
+
optional auth, optional rate limiting, and a list of *operations* — each of
|
|
6
|
+
which becomes one typed :class:`~xyberos.contracts.Tool`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from xyberos.exceptions.provider import ProviderError
|
|
19
|
+
|
|
20
|
+
_TYPE_MAP: dict[str, type] = {
|
|
21
|
+
"string": str,
|
|
22
|
+
"integer": int,
|
|
23
|
+
"number": float,
|
|
24
|
+
"boolean": bool,
|
|
25
|
+
"array": list,
|
|
26
|
+
"object": dict,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#: Where a parameter value is placed on the wire.
|
|
30
|
+
_PARAM_LOCATIONS = ("query", "path", "header", "body")
|
|
31
|
+
_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Param:
|
|
36
|
+
"""One declared operation parameter."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
in_: str = "query" # query | path | header | body
|
|
40
|
+
type: str = "string" # string | integer | number | boolean | array | object
|
|
41
|
+
required: bool = False
|
|
42
|
+
description: str = ""
|
|
43
|
+
default: Any = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def python_type(self) -> type:
|
|
47
|
+
return _TYPE_MAP.get(self.type, str)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Operation:
|
|
52
|
+
"""One declared API operation (an HTTP method + path + typed params)."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
method: str = "GET"
|
|
56
|
+
path: str = "/"
|
|
57
|
+
description: str = ""
|
|
58
|
+
params: tuple[Param, ...] = ()
|
|
59
|
+
response_format: str = "json" # json | text
|
|
60
|
+
response_path: str | None = None # dotted path extracted from a JSON body
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AuthSpec:
|
|
65
|
+
"""Authentication strategy. Secrets come from ``*_env`` variables when set."""
|
|
66
|
+
|
|
67
|
+
type: str = "none" # none | api_key | bearer | basic | oauth2
|
|
68
|
+
# api_key
|
|
69
|
+
key_name: str = "api_key"
|
|
70
|
+
key_in: str = "header" # header | query
|
|
71
|
+
value: str | None = None
|
|
72
|
+
env: str | None = None
|
|
73
|
+
# bearer
|
|
74
|
+
token: str | None = None
|
|
75
|
+
token_env: str | None = None
|
|
76
|
+
# basic
|
|
77
|
+
username: str | None = None
|
|
78
|
+
username_env: str | None = None
|
|
79
|
+
password: str | None = None
|
|
80
|
+
password_env: str | None = None
|
|
81
|
+
# oauth2 (client_credentials)
|
|
82
|
+
token_url: str | None = None
|
|
83
|
+
client_id: str | None = None
|
|
84
|
+
client_id_env: str | None = None
|
|
85
|
+
client_secret: str | None = None
|
|
86
|
+
client_secret_env: str | None = None
|
|
87
|
+
scope: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class RateLimitSpec:
|
|
92
|
+
"""Token-bucket rate limiting, backed by ``xyberos.utils.resilience.RateLimiter``."""
|
|
93
|
+
|
|
94
|
+
calls_per_second: float = 1.0
|
|
95
|
+
burst: int = 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class HttpApiSpec:
|
|
100
|
+
"""A complete declarative API connection."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
base_url: str
|
|
104
|
+
operations: list[Operation] = field(default_factory=list)
|
|
105
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
106
|
+
auth: AuthSpec = field(default_factory=AuthSpec)
|
|
107
|
+
rate_limit: RateLimitSpec | None = None
|
|
108
|
+
timeout: float = 30.0
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "HttpApiSpec":
|
|
112
|
+
"""Build a spec from a plain mapping, validating required fields."""
|
|
113
|
+
name = data.get("name")
|
|
114
|
+
base_url = data.get("base_url")
|
|
115
|
+
if not name:
|
|
116
|
+
raise ValueError("spec 'name' is required")
|
|
117
|
+
if not base_url:
|
|
118
|
+
raise ValueError("spec 'base_url' is required")
|
|
119
|
+
|
|
120
|
+
operations: list[Operation] = []
|
|
121
|
+
for index, raw in enumerate(data.get("operations") or ()):
|
|
122
|
+
operations.append(_operation_from_dict(raw, index))
|
|
123
|
+
|
|
124
|
+
auth = _auth_from_dict(data.get("auth") or {})
|
|
125
|
+
rate_limit = _rate_limit_from_dict(data.get("rate_limit"))
|
|
126
|
+
return cls(
|
|
127
|
+
name=str(name),
|
|
128
|
+
base_url=str(base_url).rstrip("/"),
|
|
129
|
+
operations=operations,
|
|
130
|
+
headers=dict(data.get("headers") or {}),
|
|
131
|
+
auth=auth,
|
|
132
|
+
rate_limit=rate_limit,
|
|
133
|
+
timeout=float(data.get("timeout", 30.0)),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_file(cls, path: str | Path) -> "HttpApiSpec":
|
|
138
|
+
"""Load a spec from a JSON or YAML file (YAML imported lazily)."""
|
|
139
|
+
path = Path(path)
|
|
140
|
+
text = path.read_text(encoding="utf-8")
|
|
141
|
+
if path.suffix.lower() in (".yaml", ".yml"):
|
|
142
|
+
try:
|
|
143
|
+
yaml = importlib.import_module("yaml") # optional dependency
|
|
144
|
+
except ImportError as exc: # pragma: no cover - environment specific
|
|
145
|
+
raise ProviderError(
|
|
146
|
+
"the 'PyYAML' package is required to load YAML specs; "
|
|
147
|
+
"install it with 'pip install PyYAML' or use a JSON spec"
|
|
148
|
+
) from exc
|
|
149
|
+
data: Any = yaml.safe_load(text)
|
|
150
|
+
else:
|
|
151
|
+
data = json.loads(text)
|
|
152
|
+
return cls.from_dict(data)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _operation_from_dict(raw: Mapping[str, Any], index: int) -> Operation:
|
|
156
|
+
name = raw.get("name")
|
|
157
|
+
if not name:
|
|
158
|
+
raise ValueError(f"operations[{index}] is missing a 'name'")
|
|
159
|
+
method = str(raw.get("method", "GET")).upper()
|
|
160
|
+
if method not in _METHODS:
|
|
161
|
+
raise ValueError(f"operation '{name}' has unsupported method '{method}'")
|
|
162
|
+
params: list[Param] = []
|
|
163
|
+
for param_index, p in enumerate(raw.get("params") or ()):
|
|
164
|
+
param_name = p.get("name")
|
|
165
|
+
if not param_name:
|
|
166
|
+
raise ValueError(f"operation '{name}' params[{param_index}] is missing a 'name'")
|
|
167
|
+
location = str(p.get("in", "query")).lower()
|
|
168
|
+
if location not in _PARAM_LOCATIONS:
|
|
169
|
+
raise ValueError(f"operation '{name}' param '{param_name}' has bad 'in': {location}")
|
|
170
|
+
params.append(
|
|
171
|
+
Param(
|
|
172
|
+
name=str(param_name),
|
|
173
|
+
in_=location,
|
|
174
|
+
type=str(p.get("type", "string")).lower(),
|
|
175
|
+
required=bool(p.get("required", False)),
|
|
176
|
+
description=str(p.get("description", "")),
|
|
177
|
+
default=p.get("default"),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return Operation(
|
|
181
|
+
name=str(name),
|
|
182
|
+
method=method,
|
|
183
|
+
path=str(raw.get("path", "/")),
|
|
184
|
+
description=str(raw.get("description", "")),
|
|
185
|
+
params=tuple(params),
|
|
186
|
+
response_format=str(raw.get("response_format", "json")).lower(),
|
|
187
|
+
response_path=raw.get("response_path"),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _auth_from_dict(data: Mapping[str, Any]) -> AuthSpec:
|
|
192
|
+
auth_type = str(data.get("type", "none")).lower()
|
|
193
|
+
fields: dict[str, Any] = {"type": auth_type}
|
|
194
|
+
if auth_type == "api_key":
|
|
195
|
+
fields.update(
|
|
196
|
+
key_name=str(data.get("key_name", "api_key")),
|
|
197
|
+
key_in=str(data.get("in", "header")).lower(),
|
|
198
|
+
value=data.get("value"),
|
|
199
|
+
env=data.get("env"),
|
|
200
|
+
)
|
|
201
|
+
elif auth_type == "bearer":
|
|
202
|
+
fields.update(token=data.get("token"), token_env=data.get("env") or data.get("token_env"))
|
|
203
|
+
elif auth_type == "basic":
|
|
204
|
+
fields.update(
|
|
205
|
+
username=data.get("username"),
|
|
206
|
+
username_env=data.get("username_env"),
|
|
207
|
+
password=data.get("password"),
|
|
208
|
+
password_env=data.get("password_env"),
|
|
209
|
+
)
|
|
210
|
+
elif auth_type == "oauth2":
|
|
211
|
+
fields.update(
|
|
212
|
+
token_url=data.get("token_url"),
|
|
213
|
+
client_id=data.get("client_id"),
|
|
214
|
+
client_id_env=data.get("client_id_env"),
|
|
215
|
+
client_secret=data.get("client_secret"),
|
|
216
|
+
client_secret_env=data.get("client_secret_env"),
|
|
217
|
+
scope=data.get("scope"),
|
|
218
|
+
)
|
|
219
|
+
elif auth_type != "none":
|
|
220
|
+
raise ValueError(f"unsupported auth type: {auth_type}")
|
|
221
|
+
return AuthSpec(**fields)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _rate_limit_from_dict(data: Mapping[str, Any] | None) -> RateLimitSpec | None:
|
|
225
|
+
if not data:
|
|
226
|
+
return None
|
|
227
|
+
return RateLimitSpec(
|
|
228
|
+
calls_per_second=float(data.get("calls_per_second", 1.0)),
|
|
229
|
+
burst=int(data.get("burst", 1)),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load_spec(source: Any) -> HttpApiSpec | list[HttpApiSpec]:
|
|
234
|
+
"""Load one or more specs from a dict, JSON/YAML file path, or file text."""
|
|
235
|
+
if isinstance(source, (str, Path)):
|
|
236
|
+
return HttpApiSpec.from_file(source)
|
|
237
|
+
if isinstance(source, list):
|
|
238
|
+
return [HttpApiSpec.from_dict(item) for item in source]
|
|
239
|
+
if isinstance(source, Mapping):
|
|
240
|
+
return HttpApiSpec.from_dict(source)
|
|
241
|
+
raise TypeError("spec must be a mapping, a list of mappings, or a path to a JSON/YAML file")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-http-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generic HTTP/API connector plugin (M2): point at any REST API, get typed tools
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,http,api,rest,tool
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
|
|
11
|
+
# xyberos-http-api
|
|
12
|
+
|
|
13
|
+
**Generic HTTP/API connector plugin — RFC-0019, M2.** *"Point at any REST API,
|
|
14
|
+
get typed tools."*
|
|
15
|
+
|
|
16
|
+
A declarative spec (JSON / YAML / Python `dict`) describes a `base_url`,
|
|
17
|
+
optional auth, optional rate limiting, and one *operation* per endpoint. Each
|
|
18
|
+
operation becomes a typed [`Tool`](https://docs.xyberos.com) whose parameters
|
|
19
|
+
are validated and coerced through `FunctionTool` / `coerce_arguments`.
|
|
20
|
+
|
|
21
|
+
This is the highest-leverage item after MCP: it is a dependency of the MCP
|
|
22
|
+
client (M3) and web search (M5), and it unblocks the whole multiplier chain.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install -e ./http-api
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Load a plugin from a spec file:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from xyberos import create_app
|
|
36
|
+
from xyberos_http_api import HttpApiPlugin
|
|
37
|
+
|
|
38
|
+
app = create_app()
|
|
39
|
+
app.load_plugin(HttpApiPlugin("examples/weather.json"))
|
|
40
|
+
|
|
41
|
+
app.tools.execute("get_forecast", None, latitude=40.71, longitude=-74.01)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or from a `dict` / YAML, or configure it entirely through the environment:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export HTTP_API_SPEC=/path/to/spec.json # or HTTP_API_SPEC_JSON='{...}'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The module-level `plugin` is auto-discovered via the `xyberos.plugins`
|
|
51
|
+
entry-point group; an unconfigured instance registers nothing (it logs a
|
|
52
|
+
warning instead of breaking `load_entry_points()`).
|
|
53
|
+
|
|
54
|
+
## Spec format
|
|
55
|
+
|
|
56
|
+
```jsonc
|
|
57
|
+
{
|
|
58
|
+
"name": "github",
|
|
59
|
+
"base_url": "https://api.github.com",
|
|
60
|
+
"headers": { "Accept": "application/vnd.github+json" },
|
|
61
|
+
"auth": { "type": "bearer", "token_env": "GITHUB_TOKEN" },
|
|
62
|
+
"rate_limit": { "calls_per_second": 5, "burst": 10 },
|
|
63
|
+
"operations": [
|
|
64
|
+
{
|
|
65
|
+
"name": "get_user",
|
|
66
|
+
"method": "GET",
|
|
67
|
+
"path": "/users/{username}",
|
|
68
|
+
"description": "Get a GitHub user's public profile.",
|
|
69
|
+
"params": [
|
|
70
|
+
{ "name": "username", "in": "path", "required": true },
|
|
71
|
+
{ "name": "per_page", "in": "query", "type": "integer", "default": 30 }
|
|
72
|
+
],
|
|
73
|
+
"response_path": "some.nested[0].value" // optional JSON extraction
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Parameters
|
|
80
|
+
|
|
81
|
+
Each param has `name`, `in` (`query` | `path` | `header` | `body`), `type`
|
|
82
|
+
(`string` | `integer` | `number` | `boolean` | `array` | `object`),
|
|
83
|
+
`required`, `description`, and an optional `default`. The generated tool's JSON
|
|
84
|
+
schema mirrors these, so an LLM gets a typed signature.
|
|
85
|
+
|
|
86
|
+
### Auth
|
|
87
|
+
|
|
88
|
+
| type | fields | notes |
|
|
89
|
+
| ---- | ------ | ----- |
|
|
90
|
+
| `api_key` | `key_name`, `in` (`header`/`query`), `value`/`env` | sent per request |
|
|
91
|
+
| `bearer` | `token`/`token_env` | `Authorization: Bearer <token>` |
|
|
92
|
+
| `basic` | `username`/`username_env`, `password`/`password_env` | base64 basic |
|
|
93
|
+
| `oauth2` | `token_url`, `client_id`/`client_id_env`, `client_secret`/`client_secret_env`, `scope` | client_credentials, token cached |
|
|
94
|
+
|
|
95
|
+
Secrets are read from environment variables first, then literals. No secret is
|
|
96
|
+
ever required in the spec file.
|
|
97
|
+
|
|
98
|
+
### Rate limiting
|
|
99
|
+
|
|
100
|
+
`rate_limit` uses the core `xyberos.utils.resilience.RateLimiter` (token
|
|
101
|
+
bucket) and is applied per request.
|
|
102
|
+
|
|
103
|
+
## Examples
|
|
104
|
+
|
|
105
|
+
- `examples/http_api_weather.py` — Open-Meteo (no key).
|
|
106
|
+
- `examples/http_api_github.py` — GitHub REST API.
|
|
107
|
+
|
|
108
|
+
## Tests
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install pytest
|
|
112
|
+
pytest tests/
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The tests spin up a local `http.server` and exercise the full stdlib client —
|
|
116
|
+
no external network required.
|
|
117
|
+
|
|
118
|
+
## Contract & ship location
|
|
119
|
+
|
|
120
|
+
- **Contract:** `Tool` (`FunctionTool` public API only).
|
|
121
|
+
- **Ship:** Plugin (`xyberos.plugins` entry point).
|
|
122
|
+
- **Dependencies:** `xyberos>=1.0`; everything else is standard library.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
xyberos_http_api/__init__.py,sha256=iylqaHOTtJmTn3if4Q-Sd6LMldZ9XTPfCtKe4uzrIjE,949
|
|
2
|
+
xyberos_http_api/auth.py,sha256=vr2rMIAZojCGTnbva7FBPTKZciZ0J-mL8ALH0BIG_LA,4949
|
|
3
|
+
xyberos_http_api/builder.py,sha256=s5haW3FzAbWYtEfK-Ky-kd-OYKkI7U3tnIBD4IQ7XnM,4834
|
|
4
|
+
xyberos_http_api/client.py,sha256=uFqyI7_gHmywi94u9Dm9Xy4M71vTXbpueRdD0BhQrGs,4348
|
|
5
|
+
xyberos_http_api/errors.py,sha256=gMU8PooWMZS7K0SNslnOgUXFqnJDVfse8VvWGVQySq4,648
|
|
6
|
+
xyberos_http_api/plugin.py,sha256=HTaaEBbl_JpQws21IzekTgwT9NV7GVWon5EcumMZr1k,4982
|
|
7
|
+
xyberos_http_api/spec.py,sha256=bAznNdyftuJHVjc0d2sdlgRR36mVOFIbywSNmshorvU,8386
|
|
8
|
+
xyberos_http_api-0.1.0.dist-info/METADATA,sha256=bsoSr0JYkmJzHsGpPFzjT9tH_o-xlXCl0FEw9MBP2Mk,3747
|
|
9
|
+
xyberos_http_api-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
xyberos_http_api-0.1.0.dist-info/entry_points.txt,sha256=-SVdmedK3189l6SputRUAseCwj7HQ9jldQ6B9W-t-tM,60
|
|
11
|
+
xyberos_http_api-0.1.0.dist-info/top_level.txt,sha256=Qz4epkWtSjRFxOvYd20ZeUPJjPPmfMmllEaxzP0WesM,17
|
|
12
|
+
xyberos_http_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xyberos_http_api
|