feat(clients): add Go, Rust, C# and Dart clients for ResolveSpec and FunctionSpec

This commit is contained in:
2026-09-30 22:40:33 +02:00
parent f2dbe2561c
commit eb492d52aa
38 changed files with 3356 additions and 0 deletions
+90
View File
@@ -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
}
+34
View File
@@ -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 }
}
+263
View File
@@ -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
}
}
+12
View File
@@ -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::*;
+130
View File
@@ -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,
}
}
+192
View File
@@ -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>,
}