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.
@@ -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
+ ]
@@ -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()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [xyberos.plugins]
2
+ http_api = xyberos_http_api.plugin:plugin
@@ -0,0 +1 @@
1
+ xyberos_http_api