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.
- xyberos_http_api-0.1.0/PKG-INFO +122 -0
- xyberos_http_api-0.1.0/README.md +112 -0
- xyberos_http_api-0.1.0/pyproject.toml +23 -0
- xyberos_http_api-0.1.0/setup.cfg +4 -0
- xyberos_http_api-0.1.0/tests/test_auth.py +66 -0
- xyberos_http_api-0.1.0/tests/test_builder.py +127 -0
- xyberos_http_api-0.1.0/tests/test_client.py +66 -0
- xyberos_http_api-0.1.0/tests/test_plugin.py +92 -0
- xyberos_http_api-0.1.0/tests/test_spec.py +129 -0
- xyberos_http_api-0.1.0/xyberos_http_api/__init__.py +30 -0
- xyberos_http_api-0.1.0/xyberos_http_api/auth.py +136 -0
- xyberos_http_api-0.1.0/xyberos_http_api/builder.py +138 -0
- xyberos_http_api-0.1.0/xyberos_http_api/client.py +126 -0
- xyberos_http_api-0.1.0/xyberos_http_api/errors.py +19 -0
- xyberos_http_api-0.1.0/xyberos_http_api/plugin.py +134 -0
- xyberos_http_api-0.1.0/xyberos_http_api/spec.py +241 -0
- xyberos_http_api-0.1.0/xyberos_http_api.egg-info/PKG-INFO +122 -0
- xyberos_http_api-0.1.0/xyberos_http_api.egg-info/SOURCES.txt +20 -0
- xyberos_http_api-0.1.0/xyberos_http_api.egg-info/dependency_links.txt +1 -0
- xyberos_http_api-0.1.0/xyberos_http_api.egg-info/entry_points.txt +2 -0
- xyberos_http_api-0.1.0/xyberos_http_api.egg-info/requires.txt +1 -0
- xyberos_http_api-0.1.0/xyberos_http_api.egg-info/top_level.txt +1 -0
|
@@ -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,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
|