mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 12:31:59 +00:00
feat(resolvespec-python): add Python client for ResolveSpec, HeaderSpec, FunctionSpec and WebSocketSpec
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from resolvespec import (
|
||||
AsyncHeaderSpecClient,
|
||||
HeaderSpecClient,
|
||||
ResolveSpecError,
|
||||
build_headers,
|
||||
decode_header_value,
|
||||
encode_header_value,
|
||||
get_headerspec_client,
|
||||
)
|
||||
import base64
|
||||
|
||||
CFG = dict(base_url="http://localhost:3000", token="tok")
|
||||
|
||||
|
||||
# ---- build_headers (ported from headerspec.test.ts) ----
|
||||
|
||||
def test_preload_shared_where():
|
||||
h = build_headers({"preload": [
|
||||
{"relation": "Items", "columns": ["id"], "where": "active = true"},
|
||||
{"relation": "Tags", "where": "active = true"},
|
||||
]})
|
||||
assert h["X-Preload"] == "Items:id|Tags"
|
||||
assert h["X-Preload-Where"] == "active = true"
|
||||
|
||||
|
||||
def test_preload_mixed_where_numbered():
|
||||
h = build_headers({"preload": [
|
||||
{"relation": "Items", "where": "a = 1"},
|
||||
{"relation": "Category"},
|
||||
{"relation": "Tags", "where": "b = 2"},
|
||||
]})
|
||||
assert h["X-Preload"] == "Category"
|
||||
assert "X-Preload-Where" not in h
|
||||
assert h["X-Preload-1"] == "Items" and h["X-Preload-1-Where"] == "a = 1"
|
||||
assert h["X-Preload-2"] == "Tags" and h["X-Preload-2-Where"] == "b = 2"
|
||||
|
||||
|
||||
def test_expand_joins_or_searchcols_advsql():
|
||||
h = build_headers({
|
||||
"expand": [{"relation": "Dept", "columns": ["id", "name"]}, {"relation": "Role"}],
|
||||
"custom_sql_joins": ["LEFT JOIN a ON a.id = b.id", "INNER JOIN c ON c.id = b.cid"],
|
||||
"custom_sql_or": ["x = 1", "y = 2"],
|
||||
"search_columns": ["name", "email"],
|
||||
"advanced_sql": {"total": "a + b"},
|
||||
})
|
||||
assert h["X-Expand"] == "Dept:id,name|Role"
|
||||
assert h["X-Custom-SQL-Join"] == "LEFT JOIN a ON a.id = b.id|INNER JOIN c ON c.id = b.cid"
|
||||
assert h["X-Custom-SQL-Or"] == "x = 1 OR y = 2"
|
||||
assert h["X-SearchCols"] == "name,email"
|
||||
assert h["X-AdvSQL-total"] == "a + b"
|
||||
|
||||
|
||||
def test_flags_pkrow_format():
|
||||
h = build_headers({
|
||||
"clean_json": True, "distinct": True, "skip_count": True, "skip_cache": False,
|
||||
"atomic_transaction": True, "single_record_as_object": False,
|
||||
"pk_row": "42", "response_format": "detail",
|
||||
})
|
||||
assert h["X-Clean-JSON"] == "true"
|
||||
assert h["X-Distinct"] == "true"
|
||||
assert h["X-SkipCount"] == "true"
|
||||
assert h["X-SkipCache"] == "false"
|
||||
assert h["X-Transaction-Atomic"] == "true"
|
||||
assert h["X-Single-Record-As-Object"] == "false"
|
||||
assert h["X-PKRow"] == "42"
|
||||
assert h["X-DetailApi"] == "true"
|
||||
|
||||
|
||||
def test_spatial_and_vector_filters():
|
||||
h = build_headers({"filters": [
|
||||
{"column": "geom", "operator": "st_dwithin", "value": {"geom": "POINT(0 0)", "distance": 5}, "logic_operator": "OR"},
|
||||
{"column": "emb", "operator": "cosine_within", "value": {"vector": [1, 2], "distance": 0.3}},
|
||||
]})
|
||||
assert json.loads(h["X-SpatialFilter-geom"]) == {
|
||||
"op": "st_dwithin", "value": {"geom": "POINT(0 0)", "distance": 5}, "logic": "or"}
|
||||
assert json.loads(h["X-VectorFilter-emb"])["op"] == "cosine_within"
|
||||
|
||||
|
||||
def test_vector_search():
|
||||
h = build_headers({"vector_search": {"column": "emb", "vector": [0.1, 0.2], "metric": "cosine", "as": "dist", "direction": "desc"}})
|
||||
assert h["X-Vector-Search-emb"] == "cosine"
|
||||
assert h["X-Vector-Search-Vector"] == "[0.1,0.2]"
|
||||
assert h["X-Vector-Search-As"] == "dist"
|
||||
assert h["X-Vector-Search-Dir"] == "desc"
|
||||
|
||||
|
||||
def test_xfiles_zip():
|
||||
xf = {"tablename": "users", "prefix": "USR", "limit": 10}
|
||||
h = build_headers({"xfiles": xf})
|
||||
assert h["X-Files"].startswith("ZIP_")
|
||||
assert json.loads(decode_header_value(h["X-Files"])) == xf
|
||||
|
||||
|
||||
def test_columns_and_omit():
|
||||
assert build_headers({"columns": ["id", "name", "email"]})["X-Select-Fields"] == "id,name,email"
|
||||
assert build_headers({"omit_columns": ["secret", "internal"]})["X-Not-Select-Fields"] == "secret,internal"
|
||||
|
||||
|
||||
def test_filters():
|
||||
assert build_headers({"filters": [{"column": "status", "operator": "eq", "value": "active"}]})["X-FieldFilter-status"] == "active"
|
||||
assert build_headers({"filters": [{"column": "age", "operator": "gte", "value": 18}]})["X-SearchOp-greaterthanorequal-age"] == "18"
|
||||
assert build_headers({"filters": [{"column": "name", "operator": "contains", "value": "test", "logic_operator": "OR"}]})["X-SearchOr-contains-name"] == "test"
|
||||
assert build_headers({"filters": [{"column": "price", "operator": "between", "value": [10, 100]}]})["X-SearchOp-between-price"] == "10,100"
|
||||
assert build_headers({"filters": [{"column": "deleted_at", "operator": "is_null", "value": None}]})["X-SearchOp-empty-deleted_at"] == ""
|
||||
assert build_headers({"filters": [{"column": "id", "operator": "in", "value": [1, 2, 3]}]})["X-SearchOp-in-id"] == "1,2,3"
|
||||
assert build_headers({"filters": [{"column": "a", "operator": "eq", "value": True}]})["X-FieldFilter-a"] == "true"
|
||||
|
||||
|
||||
def test_sort_pagination_cursor():
|
||||
h = build_headers({
|
||||
"sort": [{"column": "name", "direction": "asc"}, {"column": "created_at", "direction": "DESC"}],
|
||||
"limit": 25, "offset": 0, "cursor_forward": "abc", "cursor_backward": "xyz",
|
||||
})
|
||||
assert h["X-Sort"] == "+name,-created_at"
|
||||
assert h["X-Limit"] == "25" and h["X-Offset"] == "0"
|
||||
assert h["X-Cursor-Forward"] == "abc" and h["X-Cursor-Backward"] == "xyz"
|
||||
|
||||
|
||||
def test_preload_basic_rownumber_computed_custom():
|
||||
h = build_headers({
|
||||
"preload": [{"relation": "Items", "columns": ["id", "name"]}, {"relation": "Category"}],
|
||||
"fetch_row_number": "42",
|
||||
"computedColumns": [{"name": "total", "expression": "price * qty"}],
|
||||
"customOperators": [{"name": "a", "sql": "status = 'active'"}, {"name": "v", "sql": "verified = true"}],
|
||||
})
|
||||
assert h["X-Preload"] == "Items:id,name|Category"
|
||||
assert h["X-Fetch-RowNumber"] == "42"
|
||||
assert h["X-CQL-SEL-total"] == "price * qty"
|
||||
assert h["X-Custom-SQL-W"] == "status = 'active' AND verified = true"
|
||||
|
||||
|
||||
def test_empty_options():
|
||||
assert build_headers({}) == {}
|
||||
|
||||
|
||||
# ---- encode / decode ----
|
||||
|
||||
def test_roundtrip():
|
||||
for s in ("some complex value with spaces & symbols!", "café ☕ 你好"):
|
||||
enc = encode_header_value(s)
|
||||
assert enc.startswith("ZIP_")
|
||||
assert decode_header_value(enc) == s
|
||||
|
||||
|
||||
def test_decode_double_underscore_and_plain():
|
||||
assert decode_header_value("__" + base64.b64encode(b"hello").decode()) == "hello"
|
||||
assert decode_header_value("__" + base64.b64encode("café ☕".encode()).decode()) == "café ☕"
|
||||
assert decode_header_value("plain") == "plain"
|
||||
|
||||
|
||||
def test_decode_nested():
|
||||
assert decode_header_value(encode_header_value(encode_header_value("x"))) == "x"
|
||||
|
||||
|
||||
# ---- client ----
|
||||
|
||||
def make(handler, cls=HeaderSpecClient, **kw):
|
||||
return cls(**{**CFG, **kw}, transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def test_read_sends_get_with_headers():
|
||||
seen = []
|
||||
|
||||
def handler(req):
|
||||
seen.append(req)
|
||||
return httpx.Response(200, json=[{"id": 1}], headers={"content-range": "0-9/100", "x-limit": "10"})
|
||||
|
||||
with make(handler) as c:
|
||||
res = c.read("public", "users", options={"columns": ["id", "name"], "limit": 10})
|
||||
r = seen[0]
|
||||
assert str(r.url) == "http://localhost:3000/public/users"
|
||||
assert r.method == "GET"
|
||||
assert r.headers["x-select-fields"] == "id,name"
|
||||
assert r.headers["x-limit"] == "10"
|
||||
assert r.headers["authorization"] == "Bearer tok"
|
||||
assert res["success"] is True
|
||||
assert res["data"] == [{"id": 1}]
|
||||
assert res["metadata"] == {"count": 100, "total": 100, "filtered": 100, "offset": 0, "limit": 10}
|
||||
|
||||
|
||||
def test_metadata_defaults_without_content_range():
|
||||
with make(lambda r: httpx.Response(200, json=[])) as c:
|
||||
assert c.read("public", "users")["metadata"]["total"] == 0
|
||||
|
||||
|
||||
def test_read_with_id_create_update_delete():
|
||||
seen = []
|
||||
|
||||
def handler(req):
|
||||
seen.append(req)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
with make(handler) as c:
|
||||
c.read("public", "users", "42")
|
||||
c.create("public", "users", {"name": "Test"})
|
||||
c.update("public", "users", "1", {"name": "Updated"}, {"filters": [{"column": "active", "operator": "eq", "value": True}]})
|
||||
c.delete("public", "users", "1")
|
||||
assert str(seen[0].url) == "http://localhost:3000/public/users/42"
|
||||
assert seen[1].method == "POST" and json.loads(seen[1].content) == {"name": "Test"}
|
||||
assert seen[2].method == "PUT" and str(seen[2].url).endswith("/public/users/1")
|
||||
assert seen[2].headers["x-fieldfilter-active"] == "true"
|
||||
assert seen[3].method == "DELETE"
|
||||
|
||||
|
||||
def test_error_response():
|
||||
with make(lambda r: httpx.Response(400, json={"error": {"code": "err", "message": "fail"}})) as c:
|
||||
with pytest.raises(ResolveSpecError, match="fail") as ei:
|
||||
c.read("public", "users")
|
||||
assert ei.value.status_code == 400 and ei.value.code == "err"
|
||||
|
||||
|
||||
def test_error_non_json():
|
||||
with make(lambda r: httpx.Response(502, text="bad gateway")) as c:
|
||||
with pytest.raises(ResolveSpecError, match="bad gateway") as ei:
|
||||
c.read("public", "users")
|
||||
assert ei.value.status_code == 502
|
||||
|
||||
|
||||
async def test_async_client():
|
||||
async def handler(req):
|
||||
return httpx.Response(200, json=[{"id": 1}])
|
||||
|
||||
async with AsyncHeaderSpecClient(**CFG, transport=httpx.MockTransport(handler)) as c:
|
||||
res = await c.read("public", "users", options={"limit": 1})
|
||||
assert res["data"] == [{"id": 1}]
|
||||
|
||||
|
||||
def test_singleton():
|
||||
a = get_headerspec_client("http://hs-singleton:3000")
|
||||
assert a is get_headerspec_client("http://hs-singleton:3000")
|
||||
assert a is not get_headerspec_client("http://hs-singleton-b:3000")
|
||||
Reference in New Issue
Block a user