Files
ResolveSpec/clients/resolvespec-python/src/resolvespec/headerspec.py
T

337 lines
12 KiB
Python

"""HeaderSpec client: query options sent as HTTP headers (Go restheadspec).
Methods: GET=read, POST=create, PUT=update, DELETE=delete.
"""
from __future__ import annotations
import base64
import json
import re
from typing import Any, Dict, Mapping, Optional
import httpx
from .http import build_url, client_headers, error_from, merge_headers, parse_json
from .types import APIResponse, FilterOption, HeaderSpecOptions
_PREFIXES = ("ZIP_", "__")
_OPERATOR_MAP = {
"eq": "equals",
"neq": "notequals",
"gt": "greaterthan",
"gte": "greaterthanorequal",
"lt": "lessthan",
"lte": "lessthanorequal",
"like": "contains",
"ilike": "contains",
"contains": "contains",
"startswith": "beginswith",
"endswith": "endswith",
"in": "in",
"between": "between",
"between_inclusive": "betweeninclusive",
"is_null": "empty",
"is_not_null": "notempty",
}
def encode_header_value(value: str) -> str:
"""Base64 (UTF-8) with ZIP_ prefix, for complex header values."""
return "ZIP_" + base64.b64encode(value.encode("utf-8")).decode("ascii")
def decode_header_value(value: str) -> str:
"""Decode a value that may carry a ZIP_ or __ base64 prefix (nested allowed)."""
code = value
for prefix in _PREFIXES:
if code.startswith(prefix):
b64 = re.sub(r"[\n\r ]", "", code[len(prefix):])
b64 += "=" * (-len(b64) % 4)
code = base64.b64decode(b64).decode("utf-8")
break
if code.startswith(_PREFIXES):
code = decode_header_value(code)
return code
def _geo_header(operator: str) -> Optional[str]:
op = operator.lower()
if op.endswith("_within"):
return "X-VectorFilter-"
if op.startswith("st_") or op in ("bbox", "&&"):
return "X-SpatialFilter-"
return None
def _filter_value(f: FilterOption) -> str:
v = f.get("value")
if v is None:
return ""
if isinstance(v, (list, tuple)):
return ",".join(_scalar(x) for x in v)
return _scalar(v)
def _scalar(v: Any) -> str:
if isinstance(v, bool): # match JS String(true)
return "true" if v else "false"
return str(v)
def _bool(v: bool) -> str:
return "true" if v else "false"
def _preload_spec(p: Mapping[str, Any]) -> str:
cols = p.get("columns")
return f"{p['relation']}:{','.join(cols)}" if cols else p["relation"]
def build_headers(options: HeaderSpecOptions) -> Dict[str, str]:
"""Build restheadspec HTTP headers from options. See README for the mapping."""
h: Dict[str, str] = {}
o = options
if o.get("columns"):
h["X-Select-Fields"] = ",".join(o["columns"])
if o.get("omit_columns"):
h["X-Not-Select-Fields"] = ",".join(o["omit_columns"])
for f in o.get("filters") or []:
logic = f.get("logic_operator") or "AND"
operator = f["operator"]
op = _OPERATOR_MAP.get(operator, operator)
value = _filter_value(f)
geo = _geo_header(operator)
if geo:
payload: Dict[str, Any] = {"op": operator, "value": f.get("value")}
if logic == "OR":
payload["logic"] = "or"
h[f"{geo}{f['column']}"] = json.dumps(payload, separators=(",", ":"))
elif operator == "eq" and logic == "AND":
h[f"X-FieldFilter-{f['column']}"] = value
elif logic == "OR":
h[f"X-SearchOr-{op}-{f['column']}"] = value
else:
h[f"X-SearchOp-{op}-{f['column']}"] = value
if o.get("sort"):
h["X-Sort"] = ",".join(
("-" if s["direction"].upper() == "DESC" else "+") + s["column"] for s in o["sort"]
)
if o.get("limit") is not None:
h["X-Limit"] = str(o["limit"])
if o.get("offset") is not None:
h["X-Offset"] = str(o["offset"])
if o.get("cursor_forward"):
h["X-Cursor-Forward"] = o["cursor_forward"]
if o.get("cursor_backward"):
h["X-Cursor-Backward"] = o["cursor_backward"]
if o.get("preload"):
# Go applies X-Preload-Where to every preload in the matching X-Preload header,
# so preloads are grouped by where clause.
groups: Dict[str, list] = {}
for p in o["preload"]:
groups.setdefault(p.get("where") or "", []).append(_preload_spec(p))
n = 0
for where, specs in groups.items():
if not where:
h["X-Preload"] = "|".join(specs)
elif "" not in groups and n == 0:
# X-Preload-Where would also apply to a where-less X-Preload, so only use it alone
h["X-Preload"] = "|".join(specs)
h["X-Preload-Where"] = where
n += 1
else:
n += 1
h[f"X-Preload-{n}"] = "|".join(specs)
h[f"X-Preload-{n}-Where"] = where
if o.get("expand"):
h["X-Expand"] = "|".join(_preload_spec(e) for e in o["expand"])
if o.get("custom_sql_joins"):
h["X-Custom-SQL-Join"] = "|".join(o["custom_sql_joins"])
if o.get("custom_sql_or"):
h["X-Custom-SQL-Or"] = " OR ".join(o["custom_sql_or"])
if o.get("search_columns"):
h["X-SearchCols"] = ",".join(o["search_columns"])
for col, sql in (o.get("advanced_sql") or {}).items():
h[f"X-AdvSQL-{col}"] = sql
vs = o.get("vector_search")
if vs:
h[f"X-Vector-Search-{vs['column']}"] = vs.get("metric") or "l2"
h["X-Vector-Search-Vector"] = json.dumps(vs["vector"], separators=(",", ":"))
if vs.get("as"):
h["X-Vector-Search-As"] = vs["as"]
if vs.get("direction"):
h["X-Vector-Search-Dir"] = vs["direction"]
for name, key in (
("X-Clean-JSON", "clean_json"),
("X-Distinct", "distinct"),
("X-SkipCount", "skip_count"),
("X-SkipCache", "skip_cache"),
("X-Transaction-Atomic", "atomic_transaction"),
("X-Single-Record-As-Object", "single_record_as_object"),
):
if o.get(key) is not None:
h[name] = _bool(o[key])
if o.get("pk_row"):
h["X-PKRow"] = o["pk_row"]
fmt = o.get("response_format")
if fmt:
h[{"simple": "X-SimpleApi", "detail": "X-DetailApi", "syncfusion": "X-Syncfusion"}[fmt]] = "true"
if o.get("xfiles"):
h["X-Files"] = encode_header_value(json.dumps(o["xfiles"], separators=(",", ":")))
if o.get("fetch_row_number"):
h["X-Fetch-RowNumber"] = o["fetch_row_number"]
for cc in o.get("computedColumns") or []:
h[f"X-CQL-SEL-{cc['name']}"] = cc["expression"]
if o.get("customOperators"):
h["X-Custom-SQL-W"] = " AND ".join(co["sql"] for co in o["customOperators"])
return h
def _int(s: Optional[str]) -> int:
try:
return int(s) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0
def _wrap(response: httpx.Response) -> APIResponse:
"""Wrap a raw restheadspec body, deriving metadata from Content-Range / X-Limit."""
data = parse_json(response)
if not response.is_success:
raise error_from(response, data)
cr = response.headers.get("content-range")
total = _int(cr.split("/")[-1]) if cr else 0
offset = _int(cr.split("/")[0].split("-")[0].split(" ")[-1]) if cr else 0
return {
"data": data,
"success": True,
"error": data.get("error") if isinstance(data, dict) else None,
"metadata": {
"count": total,
"total": total,
"filtered": total,
"offset": offset,
"limit": _int(response.headers.get("x-limit")),
},
}
class _Base:
def __init__(
self,
base_url: str,
token: Optional[str] = None,
headers: Optional[Mapping[str, str]] = None,
timeout: Optional[float] = 30.0,
):
self.base_url = base_url
self.token = token
self.headers = dict(headers or {}) # snapshot
self.timeout = timeout
def _base_headers(self) -> Dict[str, str]:
return client_headers(self.token, self.headers)
def _req(self, method, schema, entity, id, options=None, body=None):
opt = build_headers(options) if options else {}
return (
method,
build_url(self.base_url, schema, entity, id),
merge_headers(self._base_headers(), opt),
body,
)
def _read_req(self, schema, entity, id, options):
return self._req("GET", schema, entity, id, options)
def _create_req(self, schema, entity, data, options):
return self._req("POST", schema, entity, None, options, data)
def _update_req(self, schema, entity, id, data, options):
return self._req("PUT", schema, entity, id, options, data)
def _delete_req(self, schema, entity, id):
return self._req("DELETE", schema, entity, id)
class HeaderSpecClient(_Base):
"""Synchronous client. Use as a context manager or call close()."""
def __init__(self, *args: Any, transport: Optional[httpx.BaseTransport] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._http = httpx.Client(timeout=self.timeout, transport=transport)
def close(self) -> None:
self._http.close()
def __enter__(self) -> "HeaderSpecClient":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
def _send(self, req) -> APIResponse:
method, url, headers, body = req
return _wrap(self._http.request(method, url, headers=headers, json=body))
def read(self, schema: str, entity: str, id: Optional[str] = None, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return self._send(self._read_req(schema, entity, id, options))
def create(self, schema: str, entity: str, data: Any, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return self._send(self._create_req(schema, entity, data, options))
def update(self, schema: str, entity: str, id: str, data: Any, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return self._send(self._update_req(schema, entity, id, data, options))
def delete(self, schema: str, entity: str, id: str) -> APIResponse:
return self._send(self._delete_req(schema, entity, id))
class AsyncHeaderSpecClient(_Base):
"""Asyncio client. Use as an async context manager or await aclose()."""
def __init__(self, *args: Any, transport: Optional[httpx.AsyncBaseTransport] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._http = httpx.AsyncClient(timeout=self.timeout, transport=transport)
async def aclose(self) -> None:
await self._http.aclose()
async def __aenter__(self) -> "AsyncHeaderSpecClient":
return self
async def __aexit__(self, *exc: Any) -> None:
await self.aclose()
async def _send(self, req) -> APIResponse:
method, url, headers, body = req
return _wrap(await self._http.request(method, url, headers=headers, json=body))
async def read(self, schema: str, entity: str, id: Optional[str] = None, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return await self._send(self._read_req(schema, entity, id, options))
async def create(self, schema: str, entity: str, data: Any, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return await self._send(self._create_req(schema, entity, data, options))
async def update(self, schema: str, entity: str, id: str, data: Any, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return await self._send(self._update_req(schema, entity, id, data, options))
async def delete(self, schema: str, entity: str, id: str) -> APIResponse:
return await self._send(self._delete_req(schema, entity, id))