xyberos-http-api 0.1.0__tar.gz

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,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,112 @@
1
+ # xyberos-http-api
2
+
3
+ **Generic HTTP/API connector plugin — RFC-0019, M2.** *"Point at any REST API,
4
+ get typed tools."*
5
+
6
+ A declarative spec (JSON / YAML / Python `dict`) describes a `base_url`,
7
+ optional auth, optional rate limiting, and one *operation* per endpoint. Each
8
+ operation becomes a typed [`Tool`](https://docs.xyberos.com) whose parameters
9
+ are validated and coerced through `FunctionTool` / `coerce_arguments`.
10
+
11
+ This is the highest-leverage item after MCP: it is a dependency of the MCP
12
+ client (M3) and web search (M5), and it unblocks the whole multiplier chain.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install -e ./http-api
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ Load a plugin from a spec file:
23
+
24
+ ```python
25
+ from xyberos import create_app
26
+ from xyberos_http_api import HttpApiPlugin
27
+
28
+ app = create_app()
29
+ app.load_plugin(HttpApiPlugin("examples/weather.json"))
30
+
31
+ app.tools.execute("get_forecast", None, latitude=40.71, longitude=-74.01)
32
+ ```
33
+
34
+ Or from a `dict` / YAML, or configure it entirely through the environment:
35
+
36
+ ```bash
37
+ export HTTP_API_SPEC=/path/to/spec.json # or HTTP_API_SPEC_JSON='{...}'
38
+ ```
39
+
40
+ The module-level `plugin` is auto-discovered via the `xyberos.plugins`
41
+ entry-point group; an unconfigured instance registers nothing (it logs a
42
+ warning instead of breaking `load_entry_points()`).
43
+
44
+ ## Spec format
45
+
46
+ ```jsonc
47
+ {
48
+ "name": "github",
49
+ "base_url": "https://api.github.com",
50
+ "headers": { "Accept": "application/vnd.github+json" },
51
+ "auth": { "type": "bearer", "token_env": "GITHUB_TOKEN" },
52
+ "rate_limit": { "calls_per_second": 5, "burst": 10 },
53
+ "operations": [
54
+ {
55
+ "name": "get_user",
56
+ "method": "GET",
57
+ "path": "/users/{username}",
58
+ "description": "Get a GitHub user's public profile.",
59
+ "params": [
60
+ { "name": "username", "in": "path", "required": true },
61
+ { "name": "per_page", "in": "query", "type": "integer", "default": 30 }
62
+ ],
63
+ "response_path": "some.nested[0].value" // optional JSON extraction
64
+ }
65
+ ]
66
+ }
67
+ ```
68
+
69
+ ### Parameters
70
+
71
+ Each param has `name`, `in` (`query` | `path` | `header` | `body`), `type`
72
+ (`string` | `integer` | `number` | `boolean` | `array` | `object`),
73
+ `required`, `description`, and an optional `default`. The generated tool's JSON
74
+ schema mirrors these, so an LLM gets a typed signature.
75
+
76
+ ### Auth
77
+
78
+ | type | fields | notes |
79
+ | ---- | ------ | ----- |
80
+ | `api_key` | `key_name`, `in` (`header`/`query`), `value`/`env` | sent per request |
81
+ | `bearer` | `token`/`token_env` | `Authorization: Bearer <token>` |
82
+ | `basic` | `username`/`username_env`, `password`/`password_env` | base64 basic |
83
+ | `oauth2` | `token_url`, `client_id`/`client_id_env`, `client_secret`/`client_secret_env`, `scope` | client_credentials, token cached |
84
+
85
+ Secrets are read from environment variables first, then literals. No secret is
86
+ ever required in the spec file.
87
+
88
+ ### Rate limiting
89
+
90
+ `rate_limit` uses the core `xyberos.utils.resilience.RateLimiter` (token
91
+ bucket) and is applied per request.
92
+
93
+ ## Examples
94
+
95
+ - `examples/http_api_weather.py` — Open-Meteo (no key).
96
+ - `examples/http_api_github.py` — GitHub REST API.
97
+
98
+ ## Tests
99
+
100
+ ```bash
101
+ pip install pytest
102
+ pytest tests/
103
+ ```
104
+
105
+ The tests spin up a local `http.server` and exercise the full stdlib client —
106
+ no external network required.
107
+
108
+ ## Contract & ship location
109
+
110
+ - **Contract:** `Tool` (`FunctionTool` public API only).
111
+ - **Ship:** Plugin (`xyberos.plugins` entry point).
112
+ - **Dependencies:** `xyberos>=1.0`; everything else is standard library.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xyberos-http-api"
7
+ version = "0.1.0"
8
+ description = "Generic HTTP/API connector plugin (M2): point at any REST API, get typed tools"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Apache-2.0"}
12
+ dependencies = ["xyberos>=1.0"]
13
+ keywords = ["xyberos", "plugin", "http", "api", "rest", "tool"]
14
+
15
+ [project.entry-points."xyberos.plugins"]
16
+ http_api = "xyberos_http_api.plugin:plugin"
17
+
18
+ [tool.setuptools]
19
+ packages = ["xyberos_http_api"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
23
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,66 @@
1
+ """Tests for auth resolution against the local HTTP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xyberos_http_api import AuthSpec
6
+ from xyberos_http_api.auth import AuthResolver
7
+
8
+
9
+ def test_no_auth():
10
+ assert AuthResolver(AuthSpec()).resolve().headers == {}
11
+
12
+
13
+ def test_api_key_header(monkeypatch):
14
+ resolver = AuthResolver(AuthSpec(type="api_key", key_name="X-API-Key", value="secret123"))
15
+ resolved = resolver.resolve()
16
+ assert resolved.headers == {"X-API-Key": "secret123"}
17
+ assert resolved.query == {}
18
+
19
+
20
+ def test_api_key_query():
21
+ resolved = AuthResolver(
22
+ AuthSpec(type="api_key", key_name="apikey", key_in="query", value="abc")
23
+ ).resolve()
24
+ assert resolved.query == {"apikey": "abc"}
25
+
26
+
27
+ def test_api_key_from_env(monkeypatch):
28
+ monkeypatch.setenv("MY_KEY", "env-value")
29
+ resolved = AuthResolver(
30
+ AuthSpec(type="api_key", key_name="X-Key", env="MY_KEY", value="literal")
31
+ ).resolve()
32
+ assert resolved.headers == {"X-Key": "env-value"}
33
+
34
+
35
+ def test_bearer():
36
+ resolved = AuthResolver(AuthSpec(type="bearer", token="tok123")).resolve()
37
+ assert resolved.headers == {"Authorization": "Bearer tok123"}
38
+
39
+
40
+ def test_basic():
41
+ resolved = AuthResolver(
42
+ AuthSpec(type="basic", username="user", password="pass")
43
+ ).resolve()
44
+ import base64
45
+
46
+ expected = "Basic " + base64.b64encode(b"user:pass").decode("ascii")
47
+ assert resolved.headers == {"Authorization": expected}
48
+
49
+
50
+ def test_oauth2_client_credentials(server):
51
+ base_url, requests = server
52
+ auth = AuthSpec(
53
+ type="oauth2",
54
+ token_url=f"{base_url}/token",
55
+ client_id="cid",
56
+ client_secret="csecret",
57
+ scope="read",
58
+ )
59
+ resolver = AuthResolver(auth)
60
+ first = resolver.resolve()
61
+ second = resolver.resolve()
62
+ assert first.headers == {"Authorization": "Bearer tok123"}
63
+ # Token is cached — only one token request is made.
64
+ token_calls = [r for r in requests if r["path"] == "/token"]
65
+ assert len(token_calls) == 1
66
+ assert second.headers == first.headers
@@ -0,0 +1,127 @@
1
+ """Tests for typed tool generation from declared operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from xyberos_http_api import HttpClient, HttpApiSpec, Operation, Param, build_operation_tool
10
+
11
+
12
+ def _spec(base_url: str, operation: Operation, **kwargs) -> HttpApiSpec:
13
+ return HttpApiSpec(name="t", base_url=base_url, operations=[operation], **kwargs)
14
+
15
+
16
+ def test_tool_schema_is_typed(server):
17
+ base_url, _ = server
18
+ op = Operation(
19
+ name="get_forecast",
20
+ method="GET",
21
+ path="/forecast",
22
+ params=(
23
+ Param("latitude", type="number", required=True),
24
+ Param("longitude", type="number", required=True),
25
+ Param("units", type="string", default="metric"),
26
+ Param("active", type="boolean", required=False, default=True),
27
+ ),
28
+ )
29
+ tool = build_operation_tool(_spec(base_url, op), op, HttpClient(base_url))
30
+ schema = tool.schema
31
+ assert schema["name"] == "get_forecast"
32
+ props = schema["parameters"]["properties"]
33
+ assert props["latitude"] == {"type": "number"}
34
+ assert props["longitude"] == {"type": "number"}
35
+ assert props["units"] == {"type": "string"}
36
+ assert props["active"] == {"type": "boolean"}
37
+ assert schema["parameters"]["required"] == ["latitude", "longitude"]
38
+
39
+
40
+ def test_tool_executes_and_coerces(server):
41
+ base_url, _ = server
42
+ op = Operation(
43
+ name="get_forecast",
44
+ path="/forecast",
45
+ params=(
46
+ Param("latitude", type="number", required=True),
47
+ Param("longitude", type="number", required=True),
48
+ Param("units", type="string", default="metric"),
49
+ ),
50
+ )
51
+ tool = build_operation_tool(_spec(base_url, op), op, HttpClient(base_url))
52
+ result = tool.execute(None, latitude="10.5", longitude="-66")
53
+ assert result["latitude"] == 10.5 # coerced from string
54
+ assert result["units"] == "metric"
55
+
56
+
57
+ def test_tool_missing_required_raises(server):
58
+ base_url, _ = server
59
+ op = Operation(
60
+ name="get_forecast",
61
+ path="/forecast",
62
+ params=(Param("latitude", type="number", required=True),),
63
+ )
64
+ tool = build_operation_tool(_spec(base_url, op), op, HttpClient(base_url))
65
+ with pytest.raises(Exception, match="latitude"):
66
+ tool.execute(None)
67
+
68
+
69
+ def test_path_param_substitution(server):
70
+ base_url, _ = server
71
+ op = Operation(
72
+ name="get_user",
73
+ path="/users/{name}",
74
+ params=(Param("name", in_="path", required=True),),
75
+ )
76
+ tool = build_operation_tool(_spec(base_url, op), op, HttpClient(base_url))
77
+ result = tool.execute(None, name="baltz")
78
+ assert result["login"] == "baltz"
79
+ assert result["public_repos"] == 42
80
+
81
+
82
+ def test_header_param_sent(server):
83
+ base_url, requests = server
84
+ op = Operation(
85
+ name="with_header",
86
+ path="/users/baltz",
87
+ params=(Param("X-Trace", in_="header", default="abc"),),
88
+ )
89
+ tool = build_operation_tool(_spec(base_url, op), op, HttpClient(base_url))
90
+ # Non-identifier names are exposed under a sanitized signature name.
91
+ assert "X_Trace" in tool.schema["parameters"]["properties"]
92
+ tool.execute(None, X_Trace="abc")
93
+ assert requests[0]["headers"].get("x-trace") == "abc"
94
+
95
+
96
+ def test_body_param(server):
97
+ base_url, _ = server
98
+ op = Operation(
99
+ name="send",
100
+ method="POST",
101
+ path="/body",
102
+ params=(Param("payload", in_="body", type="object", required=True),),
103
+ )
104
+ tool = build_operation_tool(_spec(base_url, op), op, HttpClient(base_url))
105
+ result = tool.execute(None, payload={"hello": "world"})
106
+ assert result == {"received": {"hello": "world"}}
107
+
108
+
109
+ def test_response_path_extraction(server):
110
+ base_url, _ = server
111
+ op = Operation(
112
+ name="temp",
113
+ path="/forecast",
114
+ params=(Param("latitude", type="number", required=True),),
115
+ response_path="current_weather.temperature",
116
+ )
117
+ tool = build_operation_tool(_spec(base_url, op), op, HttpClient(base_url))
118
+ assert tool.execute(None, latitude=1) == 21.5
119
+
120
+
121
+ def test_extract_path_with_indices():
122
+ from xyberos_http_api.builder import extract_path
123
+
124
+ data = {"a": [{"b": 7}, {"b": 8}]}
125
+ assert extract_path(data, "a[1].b") == 8
126
+ assert extract_path(data, "missing") is None
127
+ assert extract_path(data, None) == data
@@ -0,0 +1,66 @@
1
+ """Tests for the stdlib HTTP client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from xyberos_http_api import AuthSpec, HttpApiError, HttpClient, RateLimitSpec
8
+
9
+
10
+ def test_get_json(server):
11
+ base_url, requests = server
12
+ client = HttpClient(base_url)
13
+ result = client.get("/forecast", query={"latitude": 10.5, "longitude": -66})
14
+ assert result["latitude"] == 10.5
15
+ assert requests[0]["method"] == "GET"
16
+ assert "latitude=10.5" in requests[0]["path"]
17
+
18
+
19
+ def test_post_json_body(server):
20
+ base_url, _ = server
21
+ client = HttpClient(base_url)
22
+ result = client.post("/body", body={"name": "x", "n": 3})
23
+ assert result == {"received": {"name": "x", "n": 3}}
24
+
25
+
26
+ def test_http_error(server):
27
+ base_url, _ = server
28
+ client = HttpClient(base_url)
29
+ with pytest.raises(HttpApiError) as exc_info:
30
+ client.get("/missing")
31
+ assert exc_info.value.status == 404
32
+
33
+
34
+ def test_bearer_header_sent(server):
35
+ base_url, _ = server
36
+ client = HttpClient(base_url, auth=AuthSpec(type="bearer", token="tok123"))
37
+ assert client.get("/needs-bearer") == {"ok": True}
38
+
39
+
40
+ def test_api_key_header_sent(server):
41
+ base_url, _ = server
42
+ client = HttpClient(
43
+ base_url, auth=AuthSpec(type="api_key", key_name="X-API-Key", value="secret123")
44
+ )
45
+ assert client.get("/needs-key") == {"ok": True}
46
+
47
+
48
+ def test_declared_headers_merged(server):
49
+ base_url, requests = server
50
+ client = HttpClient(base_url, headers={"X-Custom": "yes"})
51
+ client.get("/users/baltz")
52
+ assert requests[0]["headers"]["x-custom"] == "yes"
53
+
54
+
55
+ def test_rate_limiter_throttles(server):
56
+ import time
57
+
58
+ base_url, requests = server
59
+ client = HttpClient(base_url, rate_limit=RateLimitSpec(calls_per_second=20, burst=1))
60
+ start = time.monotonic()
61
+ for _ in range(3):
62
+ client.get("/rate")
63
+ elapsed = time.monotonic() - start
64
+ # 3 calls at 20/s with burst 1 => at least ~0.1s total.
65
+ assert elapsed >= 0.09
66
+ assert len(requests) == 3
@@ -0,0 +1,92 @@
1
+ """Tests for loading the http_api plugin into a Xyberos app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+ from xyberos import create_app
9
+
10
+ from xyberos_http_api.plugin import HttpApiPlugin
11
+
12
+
13
+ def _spec_for(base_url: str) -> dict:
14
+ return {
15
+ "name": "demo",
16
+ "base_url": base_url,
17
+ "operations": [
18
+ {
19
+ "name": "get_user",
20
+ "method": "GET",
21
+ "path": "/users/{username}",
22
+ "params": [{"name": "username", "in": "path", "required": True}],
23
+ },
24
+ {
25
+ "name": "get_forecast",
26
+ "method": "GET",
27
+ "path": "/forecast",
28
+ "params": [
29
+ {"name": "latitude", "in": "query", "type": "number", "required": True},
30
+ {"name": "longitude", "in": "query", "type": "number", "required": True},
31
+ ],
32
+ },
33
+ ],
34
+ }
35
+
36
+
37
+ def test_plugin_conforms_to_contract():
38
+ plugin = HttpApiPlugin(_spec_for("https://example.com"))
39
+ assert plugin.name == "http_api"
40
+ assert callable(plugin.register) and callable(plugin.unregister)
41
+
42
+
43
+ def test_plugin_registers_and_executes(server):
44
+ base_url, _ = server
45
+ app = create_app()
46
+ plugin = HttpApiPlugin(_spec_for(base_url))
47
+ app.load_plugin(plugin)
48
+
49
+ assert "get_user" in app.tools.names
50
+ assert "get_forecast" in app.tools.names
51
+
52
+ result = app.tools.execute("get_user", None, username="baltz")
53
+ assert result["login"] == "baltz"
54
+ forecast = app.tools.execute("get_forecast", None, latitude="10.5", longitude="-66")
55
+ assert forecast["latitude"] == 10.5
56
+
57
+ app.unload_plugin(plugin.name)
58
+ assert "get_user" not in app.tools.names
59
+
60
+
61
+ def test_plugin_from_json_file(server, tmp_path):
62
+ base_url, _ = server
63
+ path = tmp_path / "spec.json"
64
+ path.write_text(json.dumps(_spec_for(base_url)), encoding="utf-8")
65
+ app = create_app()
66
+ app.load_plugin(HttpApiPlugin(path))
67
+ assert "get_user" in app.tools.names
68
+
69
+
70
+ def test_plugin_from_env(server, monkeypatch):
71
+ base_url, _ = server
72
+ monkeypatch.setenv("HTTP_API_SPEC_JSON", json.dumps(_spec_for(base_url)))
73
+ plugin = HttpApiPlugin()
74
+ assert {t.name for t in plugin.tools()} == {"get_user", "get_forecast"}
75
+
76
+
77
+ def test_unconfigured_register_is_safe(server):
78
+ base_url, _ = server
79
+ app = create_app()
80
+ plugin = HttpApiPlugin() # no spec, no env
81
+ app.load_plugin(plugin) # must not raise
82
+ assert app.plugins.names == ("http_api",)
83
+ app.unload_plugin("http_api")
84
+
85
+
86
+ def test_multi_spec_tools_are_prefixed(server):
87
+ base_url, _ = server
88
+ specs = [_spec_for(base_url), {**_spec_for(base_url), "name": "second"}]
89
+ plugin = HttpApiPlugin(specs)
90
+ names = {t.name for t in plugin.tools()}
91
+ assert "demo_get_user" in names
92
+ assert "second_get_user" in names