mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
feat(clients): add Go, Rust, C# and Dart clients for ResolveSpec and FunctionSpec
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Shared HTTP configuration.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Config {
|
||||
pub base_url: String,
|
||||
pub token: Option<String>,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub http: reqwest::Client,
|
||||
}
|
||||
|
||||
/// Builder options shared by both clients.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct ClientBuilder {
|
||||
base_url: String,
|
||||
token: Option<String>,
|
||||
headers: HashMap<String, String>,
|
||||
timeout: Option<Duration>,
|
||||
http: Option<reqwest::Client>,
|
||||
}
|
||||
|
||||
impl ClientBuilder {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self { base_url: base_url.trim_end_matches('/').into(), timeout: Some(Duration::from_secs(30)), ..Default::default() }
|
||||
}
|
||||
pub fn token(mut self, token: &str) -> Self {
|
||||
self.token = Some(token.into());
|
||||
self
|
||||
}
|
||||
pub fn header(mut self, name: &str, value: &str) -> Self {
|
||||
self.headers.insert(name.into(), value.into());
|
||||
self
|
||||
}
|
||||
pub fn timeout(mut self, t: Duration) -> Self {
|
||||
self.timeout = Some(t);
|
||||
self
|
||||
}
|
||||
pub fn http_client(mut self, c: reqwest::Client) -> Self {
|
||||
self.http = Some(c);
|
||||
self
|
||||
}
|
||||
pub(crate) fn config(self) -> Result<Config> {
|
||||
let http = match self.http {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let mut b = reqwest::Client::builder();
|
||||
if let Some(t) = self.timeout {
|
||||
b = b.timeout(t);
|
||||
}
|
||||
b.build()?
|
||||
}
|
||||
};
|
||||
Ok(Config { base_url: self.base_url, token: self.token, headers: self.headers, http })
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Content-Type < custom headers < extra (per-call) < bearer token.
|
||||
pub fn headers(&self, extra: &HashMap<String, String>) -> HeaderMap {
|
||||
let mut m = HeaderMap::new();
|
||||
m.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
for (k, v) in self.headers.iter().chain(extra.iter()) {
|
||||
if let (Ok(n), Ok(v)) = (HeaderName::try_from(k.as_str()), HeaderValue::from_str(v)) {
|
||||
m.insert(n, v);
|
||||
}
|
||||
}
|
||||
if let Some(t) = &self.token {
|
||||
if let Ok(v) = HeaderValue::from_str(&format!("Bearer {t}")) {
|
||||
m.insert(AUTHORIZATION, v);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn path_segment(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use crate::types::ApiError;
|
||||
|
||||
/// Returned on transport failure, a non-2xx response or an unsuccessful API result.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{message}")]
|
||||
Api { status: u16, message: String, error: ApiError },
|
||||
#[error(transparent)]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
pub(crate) fn error_from(status: u16, body: &str) -> Error {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let err: ApiError = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("error"))
|
||||
.and_then(|e| serde_json::from_value(e.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let message = if !err.message.is_empty() {
|
||||
err.message.clone()
|
||||
} else {
|
||||
let text = if parsed.is_none() { body.trim().chars().take(200).collect::<String>() } else { String::new() };
|
||||
if text.is_empty() {
|
||||
format!("{} ({})", reqwest::StatusCode::from_u16(status).ok().and_then(|s| s.canonical_reason()).unwrap_or("Error"), status)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
};
|
||||
Error::Api { status, message, error: err }
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{ClientBuilder, Config};
|
||||
use crate::error::{error_from, Result};
|
||||
use crate::types::{FilterOption, Metadata, Response, SortOption};
|
||||
|
||||
/// Options sent to funcspec endpoints as `X-*` headers.
|
||||
///
|
||||
/// Server behaviour (`pkg/funcspec`): `sort` is inserted raw into ORDER BY (so it is sent as SQL
|
||||
/// terms); only one search operator per column is kept; values starting with `ZIP_` or `__`
|
||||
/// are base64-decoded by the server, so such plaintext values cannot be sent faithfully.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FuncSpecOptions {
|
||||
/// eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr.
|
||||
pub filters: Vec<FilterOption>,
|
||||
/// X-SearchFilter-{col}: text ILIKE.
|
||||
pub search_filters: BTreeMap<String, String>,
|
||||
pub custom_sql_where: Option<String>,
|
||||
pub custom_sql_or: Option<String>,
|
||||
pub sort: Vec<SortOption>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
pub distinct: Option<bool>,
|
||||
pub skip_count: Option<bool>,
|
||||
pub skip_cache: Option<bool>,
|
||||
/// simple | detail | syncfusion
|
||||
pub response_format: Option<String>,
|
||||
}
|
||||
|
||||
/// Query-string parameter value. `List` is sent as repeated keys (server: IN filter).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Param {
|
||||
Str(String),
|
||||
Int(i64),
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl From<&str> for Param {
|
||||
fn from(v: &str) -> Self {
|
||||
Self::Str(v.into())
|
||||
}
|
||||
}
|
||||
impl From<String> for Param {
|
||||
fn from(v: String) -> Self {
|
||||
Self::Str(v)
|
||||
}
|
||||
}
|
||||
impl From<i64> for Param {
|
||||
fn from(v: i64) -> Self {
|
||||
Self::Int(v)
|
||||
}
|
||||
}
|
||||
impl From<bool> for Param {
|
||||
fn from(v: bool) -> Self {
|
||||
Self::Bool(v)
|
||||
}
|
||||
}
|
||||
impl From<Vec<String>> for Param {
|
||||
fn from(v: Vec<String>) -> Self {
|
||||
Self::List(v)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Params = BTreeMap<String, Param>;
|
||||
|
||||
fn operator(op: &str) -> &str {
|
||||
match op {
|
||||
"eq" => "equals",
|
||||
"neq" => "notequals",
|
||||
"gt" => "greaterthan",
|
||||
"gte" => "greaterthanorequal",
|
||||
"lt" => "lessthan",
|
||||
"lte" => "lessthanorequal",
|
||||
"like" | "ilike" | "contains" => "contains",
|
||||
"startswith" => "beginswith",
|
||||
"endswith" => "endswith",
|
||||
"in" => "in",
|
||||
"between" => "between",
|
||||
"between_inclusive" => "betweeninclusive",
|
||||
"is_null" => "empty",
|
||||
"is_not_null" => "notempty",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Null => String::new(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Array(a) => a.iter().map(scalar).collect::<Vec<_>>().join(","),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64 (UTF-8) with the `ZIP_` prefix.
|
||||
pub fn encode_header_value(v: &str) -> String {
|
||||
format!("ZIP_{}", STANDARD.encode(v.as_bytes()))
|
||||
}
|
||||
|
||||
/// Decode a value that may carry a `ZIP_` or `__` prefix (nested allowed).
|
||||
pub fn decode_header_value(v: &str) -> String {
|
||||
for p in ["ZIP_", "__"] {
|
||||
if let Some(rest) = v.strip_prefix(p) {
|
||||
let mut b64: String = rest.chars().filter(|c| !matches!(c, '\n' | '\r' | ' ')).collect();
|
||||
while b64.len() % 4 != 0 {
|
||||
b64.push('=');
|
||||
}
|
||||
return match STANDARD.decode(b64).ok().and_then(|b| String::from_utf8(b).ok()) {
|
||||
Some(s) => decode_header_value(&s),
|
||||
None => v.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
/// Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).
|
||||
fn safe(v: &str) -> String {
|
||||
if v != v.trim() || v.chars().any(|c| !c.is_ascii() || c.is_ascii_control()) {
|
||||
encode_header_value(v)
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `X-*` headers understood by `funcspec.ParseParameters`.
|
||||
pub fn build_headers(o: &FuncSpecOptions) -> BTreeMap<String, String> {
|
||||
let mut h = BTreeMap::new();
|
||||
for f in &o.filters {
|
||||
let logic = f.logic_operator.as_deref().unwrap_or("AND");
|
||||
let v = safe(&scalar(&f.value));
|
||||
if f.operator == "eq" && logic == "AND" {
|
||||
h.insert(format!("X-FieldFilter-{}", f.column), v);
|
||||
} else {
|
||||
let kind = if logic == "OR" { "X-SearchOr" } else { "X-SearchOp" };
|
||||
h.insert(format!("{kind}-{}-{}", operator(&f.operator), f.column), v);
|
||||
}
|
||||
}
|
||||
for (col, text) in &o.search_filters {
|
||||
h.insert(format!("X-SearchFilter-{col}"), safe(text));
|
||||
}
|
||||
if let Some(v) = o.custom_sql_where.as_deref().filter(|s| !s.is_empty()) {
|
||||
h.insert("X-Custom-SQL-W".into(), safe(v));
|
||||
}
|
||||
if let Some(v) = o.custom_sql_or.as_deref().filter(|s| !s.is_empty()) {
|
||||
h.insert("X-Custom-SQL-Or".into(), safe(v));
|
||||
}
|
||||
if !o.sort.is_empty() {
|
||||
let terms: Vec<String> = o
|
||||
.sort
|
||||
.iter()
|
||||
.map(|s| format!("{} {}", s.column, if s.direction.eq_ignore_ascii_case("desc") { "DESC" } else { "ASC" }))
|
||||
.collect();
|
||||
h.insert("X-Sort".into(), safe(&terms.join(","))); // funcspec puts this verbatim into ORDER BY
|
||||
}
|
||||
if let Some(n) = o.limit {
|
||||
h.insert("X-Limit".into(), n.to_string());
|
||||
}
|
||||
if let Some(n) = o.offset {
|
||||
h.insert("X-Offset".into(), n.to_string());
|
||||
}
|
||||
for (name, v) in [("X-Distinct", o.distinct), ("X-SkipCount", o.skip_count), ("X-SkipCache", o.skip_cache)] {
|
||||
if let Some(b) = v {
|
||||
h.insert(name.into(), b.to_string());
|
||||
}
|
||||
}
|
||||
match o.response_format.as_deref() {
|
||||
Some("simple") => h.insert("X-SimpleApi".into(), "true".into()),
|
||||
Some("detail") => h.insert("X-DetailApi".into(), "true".into()),
|
||||
Some("syncfusion") => h.insert("X-Syncfusion".into(), "true".into()),
|
||||
_ => None,
|
||||
};
|
||||
h
|
||||
}
|
||||
|
||||
/// Build query-string pairs: bools -> true/false, lists -> repeated keys.
|
||||
pub fn build_query(p: &Params) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in p {
|
||||
match v {
|
||||
Param::Str(s) => out.push((k.clone(), safe(s))),
|
||||
Param::Int(n) => out.push((k.clone(), n.to_string())),
|
||||
Param::Bool(b) => out.push((k.clone(), b.to_string())),
|
||||
Param::List(l) => out.extend(l.iter().map(|s| (k.clone(), safe(s)))),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn metadata(content_range: Option<&str>, limit: Option<i64>) -> Metadata {
|
||||
let mut m = Metadata { limit: limit.unwrap_or(0), ..Default::default() };
|
||||
if let Some(cr) = content_range {
|
||||
// "items {start}-{end}/{total}"
|
||||
let rest = cr.rsplit(' ').next().unwrap_or("");
|
||||
if let Some((range, total)) = rest.split_once('/') {
|
||||
if let (Some((s, e)), Ok(t)) = (range.split_once('-'), total.parse::<i64>()) {
|
||||
if let (Ok(s), Ok(e)) = (s.parse::<i64>(), e.parse::<i64>()) {
|
||||
m.total = t;
|
||||
m.filtered = t;
|
||||
m.count = e - s;
|
||||
m.offset = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Client for user-defined SQL endpoints. Routes are defined by the server application.
|
||||
#[derive(Clone)]
|
||||
pub struct FuncSpecClient {
|
||||
cfg: Config,
|
||||
}
|
||||
|
||||
impl FuncSpecClient {
|
||||
pub fn new(base_url: &str) -> Result<Self> {
|
||||
Self::from_builder(ClientBuilder::new(base_url))
|
||||
}
|
||||
|
||||
pub fn from_builder(b: ClientBuilder) -> Result<Self> {
|
||||
Ok(Self { cfg: b.config()? })
|
||||
}
|
||||
|
||||
async fn call(&self, method: Method, path: &str, params: &Params, options: Option<&FuncSpecOptions>, list: bool) -> Result<Response> {
|
||||
let url = format!("{}/{}", self.cfg.base_url, path.trim_start_matches('/'));
|
||||
let extra: HashMap<String, String> = options.map(|o| build_headers(o).into_iter().collect()).unwrap_or_default();
|
||||
let resp = self.cfg.http.request(method, url).headers(self.cfg.headers(&extra)).query(&build_query(params)).send().await?;
|
||||
let status = resp.status();
|
||||
let cr = resp.headers().get("content-range").and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
let text = resp.text().await?;
|
||||
if !status.is_success() {
|
||||
// 206 Partial Content is success
|
||||
return Err(error_from(status.as_u16(), &text));
|
||||
}
|
||||
let data = if text.trim().is_empty() { Value::Null } else { serde_json::from_str(&text)? };
|
||||
Ok(Response {
|
||||
success: true,
|
||||
data,
|
||||
metadata: list.then(|| metadata(cr.as_deref(), options.and_then(|o| o.limit))),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Single-record endpoint (`SqlQuery`). `data` is the row object.
|
||||
pub async fn query(&self, path: &str, params: &Params, options: Option<&FuncSpecOptions>) -> Result<Response> {
|
||||
self.call(Method::GET, path, params, options, false).await
|
||||
}
|
||||
|
||||
/// List endpoint (`SqlQueryList`). Metadata comes from Content-Range.
|
||||
pub async fn query_list(&self, path: &str, params: &Params, options: Option<&FuncSpecOptions>) -> Result<Response> {
|
||||
self.call(Method::GET, path, params, options, true).await
|
||||
}
|
||||
|
||||
/// Like `query` / `query_list` with an explicit HTTP method (routes are app-defined).
|
||||
pub async fn request(&self, method: Method, path: &str, params: &Params, options: Option<&FuncSpecOptions>, list: bool) -> Result<Response> {
|
||||
self.call(method, path, params, options, list).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
|
||||
mod client;
|
||||
mod error;
|
||||
mod funcspec;
|
||||
mod resolvespec;
|
||||
pub mod types;
|
||||
|
||||
pub use client::ClientBuilder;
|
||||
pub use error::{Error, Result};
|
||||
pub use funcspec::{build_headers, build_query, decode_header_value, encode_header_value, FuncSpecClient, FuncSpecOptions, Param, Params};
|
||||
pub use resolvespec::{RecordId, ResolveSpecClient};
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{path_segment, ClientBuilder, Config};
|
||||
use crate::error::{error_from, Error, Result};
|
||||
use crate::types::{Options, Response};
|
||||
|
||||
/// A record id: a single value goes in the URL, a list goes in the body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RecordId {
|
||||
Int(i64),
|
||||
Str(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
|
||||
impl From<i64> for RecordId {
|
||||
fn from(v: i64) -> Self {
|
||||
Self::Int(v)
|
||||
}
|
||||
}
|
||||
impl From<&str> for RecordId {
|
||||
fn from(v: &str) -> Self {
|
||||
Self::Str(v.into())
|
||||
}
|
||||
}
|
||||
impl From<Vec<String>> for RecordId {
|
||||
fn from(v: Vec<String>) -> Self {
|
||||
Self::Many(v)
|
||||
}
|
||||
}
|
||||
|
||||
fn url_id(id: &Option<RecordId>) -> Option<String> {
|
||||
match id {
|
||||
Some(RecordId::Int(n)) => Some(n.to_string()),
|
||||
Some(RecordId::Str(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Request<'a> {
|
||||
operation: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
options: Option<&'a Options>,
|
||||
}
|
||||
|
||||
/// Client for the ResolveSpec JSON body protocol.
|
||||
#[derive(Clone)]
|
||||
pub struct ResolveSpecClient {
|
||||
cfg: Config,
|
||||
}
|
||||
|
||||
impl ResolveSpecClient {
|
||||
pub fn new(base_url: &str) -> Result<Self> {
|
||||
Self::from_builder(ClientBuilder::new(base_url))
|
||||
}
|
||||
|
||||
pub fn from_builder(b: ClientBuilder) -> Result<Self> {
|
||||
Ok(Self { cfg: b.config()? })
|
||||
}
|
||||
|
||||
fn url(&self, schema: &str, entity: &str, id: Option<String>) -> String {
|
||||
let mut u = format!("{}/{}/{}", self.cfg.base_url, path_segment(schema), path_segment(entity));
|
||||
if let Some(id) = id.filter(|i| !i.is_empty()) {
|
||||
u.push('/');
|
||||
u.push_str(&path_segment(&id));
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
async fn send(&self, method: Method, url: String, body: Option<Request<'_>>) -> Result<Response> {
|
||||
let mut req = self.cfg.http.request(method, url).headers(self.cfg.headers(&HashMap::new()));
|
||||
if let Some(b) = body {
|
||||
req = req.body(serde_json::to_vec(&b)?);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(error_from(status.as_u16(), &text));
|
||||
}
|
||||
let out: Response = serde_json::from_str(&text)?;
|
||||
if !out.success {
|
||||
if let Some(e) = out.error.clone() {
|
||||
return Err(Error::Api { status: status.as_u16(), message: e.message.clone(), error: e });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// GET /{schema}/{entity}
|
||||
pub async fn get_metadata(&self, schema: &str, entity: &str) -> Result<Response> {
|
||||
self.send(Method::GET, self.url(schema, entity, None), None).await
|
||||
}
|
||||
|
||||
pub async fn read(&self, schema: &str, entity: &str, id: Option<RecordId>, options: Option<&Options>) -> Result<Response> {
|
||||
let body = Request { operation: "read", id: many(&id), data: None, options };
|
||||
self.send(Method::POST, self.url(schema, entity, url_id(&id)), Some(body)).await
|
||||
}
|
||||
|
||||
pub async fn create(&self, schema: &str, entity: &str, data: Value, options: Option<&Options>) -> Result<Response> {
|
||||
let body = Request { operation: "create", id: None, data: Some(data), options };
|
||||
self.send(Method::POST, self.url(schema, entity, None), Some(body)).await
|
||||
}
|
||||
|
||||
pub async fn update(&self, schema: &str, entity: &str, data: Value, id: Option<RecordId>, options: Option<&Options>) -> Result<Response> {
|
||||
let body = Request { operation: "update", id: many(&id), data: Some(data), options };
|
||||
self.send(Method::POST, self.url(schema, entity, url_id(&id)), Some(body)).await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, schema: &str, entity: &str, id: impl Into<RecordId>) -> Result<Response> {
|
||||
let id = Some(id.into());
|
||||
let body = Request { operation: "delete", id: None, data: None, options: None };
|
||||
self.send(Method::POST, self.url(schema, entity, url_id(&id)), Some(body)).await
|
||||
}
|
||||
}
|
||||
|
||||
fn many(id: &Option<RecordId>) -> Option<Vec<String>> {
|
||||
match id {
|
||||
Some(RecordId::Many(v)) => Some(v.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Types aligned with Go `pkg/common/types.go`. Field names are the wire names.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FilterOption {
|
||||
pub column: String,
|
||||
/// eq neq gt gte lt lte like ilike in contains startswith endswith between
|
||||
/// between_inclusive is_null is_not_null
|
||||
pub operator: String,
|
||||
#[serde(default)]
|
||||
pub value: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logic_operator: Option<String>, // AND | OR
|
||||
}
|
||||
|
||||
impl FilterOption {
|
||||
pub fn new(column: &str, operator: &str, value: impl Into<Value>) -> Self {
|
||||
Self { column: column.into(), operator: operator.into(), value: value.into(), logic_operator: None }
|
||||
}
|
||||
pub fn or(mut self) -> Self {
|
||||
self.logic_operator = Some("OR".into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SortOption {
|
||||
pub column: String,
|
||||
pub direction: String, // asc | desc
|
||||
}
|
||||
|
||||
impl SortOption {
|
||||
pub fn new(column: &str, direction: &str) -> Self {
|
||||
Self { column: column.into(), direction: direction.into() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Parameter {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sequence: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomOperator {
|
||||
pub name: String,
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComputedColumn {
|
||||
pub name: String,
|
||||
pub expression: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PreloadOption {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub table_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub omit_columns: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub sort: Vec<SortOption>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub filters: Vec<FilterOption>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub r#where: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updateable: Option<bool>,
|
||||
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
|
||||
pub computed_ql: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recursive: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub related_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub foreign_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recursive_child_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub sql_joins: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub join_aliases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct VectorSearchOption {
|
||||
pub column: String,
|
||||
pub vector: Vec<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metric: Option<String>, // l2 (default) | cosine | ip
|
||||
#[serde(rename = "as", skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>, // distance alias, default _distance
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<String>,
|
||||
}
|
||||
|
||||
/// ResolveSpec request options object.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Options {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub preload: Vec<PreloadOption>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub columns: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub omit_columns: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub filters: Vec<FilterOption>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub sort: Vec<SortOption>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<i64>,
|
||||
#[serde(rename = "customOperators", skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub custom_operators: Vec<CustomOperator>,
|
||||
#[serde(rename = "computedColumns", skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub computed_columns: Vec<ComputedColumn>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub parameters: Vec<Parameter>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_forward: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_backward: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fetch_row_number: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vector_search: Option<VectorSearchOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Metadata {
|
||||
#[serde(default)]
|
||||
pub total: i64,
|
||||
#[serde(default)]
|
||||
pub count: i64,
|
||||
#[serde(default)]
|
||||
pub filtered: i64,
|
||||
#[serde(default)]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// ResolveSpec envelope. `data` is left as JSON for the caller to decode.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Response {
|
||||
#[serde(default)]
|
||||
pub success: bool,
|
||||
#[serde(default)]
|
||||
pub data: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<Metadata>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ApiError>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Decode `data` into `T`.
|
||||
pub fn decode<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
|
||||
serde_json::from_value(self.data.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ApiError {
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
/// Server-side reason (funcspec / restheadspec).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sql: Option<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user