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
+12
View File
@@ -0,0 +1,12 @@
# Clients
| Dir | Language | Specs | Verified |
|---|---|---|---|
| `resolvespec-js` | TypeScript | ResolveSpec, HeaderSpec, FunctionSpec, WebSocketSpec | yes |
| `resolvespec-python` | Python >= 3.11 | ResolveSpec, HeaderSpec, FunctionSpec, WebSocketSpec | yes (61 tests) |
| `resolvespec-go` | Go | ResolveSpec, FunctionSpec | yes (`go test`) |
| `resolvespec-rs` | Rust | ResolveSpec, FunctionSpec | yes (`cargo test`) |
| `resolvespec-cs` | C# (.NET 8) | ResolveSpec, FunctionSpec | **not compiled** |
| `resolvespec-dart` | Dart / Flutter | ResolveSpec, FunctionSpec | **not compiled** |
Wire behaviour is identical across clients; FunctionSpec server quirks are listed in each README.
+2
View File
@@ -0,0 +1,2 @@
bin/
obj/
+43
View File
@@ -0,0 +1,43 @@
# ResolveSpec.Client (C#)
.NET 8 client for ResolveSpec (JSON body) and FunctionSpec. `System.Text.Json`, no other dependencies.
> Not compiled or tested yet (no .NET SDK was available). Run `dotnet test tests/` first.
## Clients
| Type | Constructor | Methods |
|---|---|---|
| `ResolveSpecClient` | `(baseUrl, ClientOptions?)` | `GetMetadataAsync` `ReadAsync` `CreateAsync` `UpdateAsync` `DeleteAsync` |
| `FuncSpecClient` | `(baseUrl, ClientOptions?)` | `QueryAsync` `QueryListAsync` |
`ClientOptions`: `Token`, `Headers`, `Timeout`, `HttpClient`. Precedence: Content-Type < custom headers < bearer token.
## ResolveSpec
- `id`: int/long/string → URL, `IEnumerable<string>` → body.
- `Options` with nullable properties; wire names via `JsonPropertyName`.
- Result: `Response{Success, Data (JsonElement), Metadata}`; `resp.Decode<T>()`.
## FunctionSpec
- Routes are server-defined: pass the `path`.
- Params (`IDictionary<string, object?>`) → query string (enumerable → repeated keys, bool → `true`/`false`, null skipped).
- `FuncSpecOptions` → `X-*` headers: `Filters`, `SearchFilters`, `CustomSqlWhere`, `CustomSqlOr`, `Sort`, `Limit`, `Offset`, `Distinct`, `SkipCount`, `SkipCache`, `ResponseFormat`.
- `QueryListAsync` fills `Metadata` from `Content-Range`; 206 is success.
- Static helpers: `BuildHeaders`, `BuildQuery`, `EncodeHeaderValue`, `DecodeHeaderValue`.
## Server quirks
- `Sort` is raw SQL in ORDER BY (client sends `col ASC|DESC`).
- One search operator per column.
- Values starting `ZIP_` / `__` are base64-decoded by the server.
- Non-ASCII, control chars and edge spaces are auto-encoded (`ZIP_`).
## Errors
`ResolveSpecException{StatusCode, Message, Error{Code, Detail, Sql}}`.
## Test
`dotnet test tests/`
@@ -0,0 +1,185 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ResolveSpec;
/// <summary>
/// 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.
/// </summary>
public sealed class FuncSpecOptions
{
/// <summary>eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr.</summary>
public List<FilterOption>? Filters { get; set; }
/// <summary>X-SearchFilter-{col}: text ILIKE.</summary>
public Dictionary<string, string>? SearchFilters { get; set; }
public string? CustomSqlWhere { get; set; }
public string? CustomSqlOr { get; set; }
public List<SortOption>? Sort { get; set; }
public int? Limit { get; set; }
public int? Offset { get; set; }
public bool? Distinct { get; set; }
public bool? SkipCount { get; set; }
public bool? SkipCache { get; set; }
/// <summary>simple | detail | syncfusion</summary>
public string? ResponseFormat { get; set; }
}
/// <summary>Client for user-defined SQL endpoints. Routes are defined by the server application.</summary>
public sealed class FuncSpecClient
{
readonly Transport _t;
public FuncSpecClient(string baseUrl, ClientOptions? options = null) => _t = new Transport(baseUrl, options);
static readonly Dictionary<string, string> OperatorMap = new()
{
["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",
};
static string Scalar(object? v) => v switch
{
null => "",
string s => s,
bool b => b ? "true" : "false",
JsonElement { ValueKind: JsonValueKind.Null } => "",
JsonElement e => e.ValueKind == JsonValueKind.String ? e.GetString() ?? "" : e.ToString(),
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => v.ToString() ?? "",
};
static string FilterValue(object? v) =>
v is System.Collections.IEnumerable list and not string
? string.Join(",", list.Cast<object?>().Select(Scalar))
: Scalar(v);
/// <summary>Base64 (UTF-8) with the ZIP_ prefix.</summary>
public static string EncodeHeaderValue(string v) => "ZIP_" + Convert.ToBase64String(Encoding.UTF8.GetBytes(v));
/// <summary>Decode a value that may carry a ZIP_ or __ prefix (nested allowed).</summary>
public static string DecodeHeaderValue(string v)
{
foreach (var p in new[] { "ZIP_", "__" })
{
if (!v.StartsWith(p, StringComparison.Ordinal)) continue;
var b64 = Regex.Replace(v[p.Length..], "[\n\r ]", "");
b64 = b64.PadRight(b64.Length + (4 - b64.Length % 4) % 4, '=');
try { return DecodeHeaderValue(Encoding.UTF8.GetString(Convert.FromBase64String(b64))); }
catch (FormatException) { return v; }
}
return v;
}
/// <summary>Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).</summary>
static string Safe(string v) =>
v != v.Trim() || v.Any(c => c > 127 || char.IsControl(c)) ? EncodeHeaderValue(v) : v;
/// <summary>Build the X-* headers understood by funcspec.ParseParameters.</summary>
public static Dictionary<string, string> BuildHeaders(FuncSpecOptions? o)
{
var h = new Dictionary<string, string>();
if (o == null) return h;
foreach (var f in o.Filters ?? new())
{
var logic = string.IsNullOrEmpty(f.LogicOperator) ? "AND" : f.LogicOperator;
var v = Safe(FilterValue(f.Value));
if (f.Operator == "eq" && logic == "AND") { h[$"X-FieldFilter-{f.Column}"] = v; continue; }
var op = OperatorMap.TryGetValue(f.Operator, out var m) ? m : f.Operator;
h[$"{(logic == "OR" ? "X-SearchOr" : "X-SearchOp")}-{op}-{f.Column}"] = v;
}
foreach (var (col, text) in o.SearchFilters ?? new()) h[$"X-SearchFilter-{col}"] = Safe(text);
if (!string.IsNullOrEmpty(o.CustomSqlWhere)) h["X-Custom-SQL-W"] = Safe(o.CustomSqlWhere);
if (!string.IsNullOrEmpty(o.CustomSqlOr)) h["X-Custom-SQL-Or"] = Safe(o.CustomSqlOr);
if (o.Sort is { Count: > 0 })
{
// funcspec puts this verbatim into ORDER BY
h["X-Sort"] = Safe(string.Join(",", o.Sort.Select(s =>
$"{s.Column} {(string.Equals(s.Direction, "desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC")}")));
}
if (o.Limit != null) h["X-Limit"] = o.Limit.Value.ToString(CultureInfo.InvariantCulture);
if (o.Offset != null) h["X-Offset"] = o.Offset.Value.ToString(CultureInfo.InvariantCulture);
if (o.Distinct != null) h["X-Distinct"] = Bool(o.Distinct.Value);
if (o.SkipCount != null) h["X-SkipCount"] = Bool(o.SkipCount.Value);
if (o.SkipCache != null) h["X-SkipCache"] = Bool(o.SkipCache.Value);
switch (o.ResponseFormat)
{
case "simple": h["X-SimpleApi"] = "true"; break;
case "detail": h["X-DetailApi"] = "true"; break;
case "syncfusion": h["X-Syncfusion"] = "true"; break;
}
return h;
}
static string Bool(bool b) => b ? "true" : "false";
/// <summary>Build query-string pairs: bools -> true/false, lists -> repeated keys, null skipped.</summary>
public static List<KeyValuePair<string, string>> BuildQuery(IDictionary<string, object?>? p)
{
var o = new List<KeyValuePair<string, string>>();
foreach (var (k, v) in p ?? new Dictionary<string, object?>())
{
if (v == null) continue;
if (v is System.Collections.IEnumerable list and not string)
foreach (var e in list) o.Add(new(k, Safe(Scalar(e))));
else o.Add(new(k, Safe(Scalar(v))));
}
return o;
}
static readonly Regex ContentRange = new(@"(\d+)-(\d+)/(\d+)");
static Metadata MetadataFrom(string? contentRange, FuncSpecOptions? o)
{
var m = new Metadata { Limit = o?.Limit ?? 0 };
var g = ContentRange.Match(contentRange ?? "");
if (g.Success)
{
var start = long.Parse(g.Groups[1].Value, CultureInfo.InvariantCulture);
var end = long.Parse(g.Groups[2].Value, CultureInfo.InvariantCulture);
var total = long.Parse(g.Groups[3].Value, CultureInfo.InvariantCulture);
m.Total = total; m.Filtered = total; m.Count = end - start; m.Offset = start;
}
return m;
}
async Task<Response> CallAsync(HttpMethod method, string path, IDictionary<string, object?>? p, FuncSpecOptions? o, bool list, CancellationToken ct)
{
var url = $"{_t.BaseUrl}/{path.TrimStart('/')}";
var q = BuildQuery(p);
if (q.Count > 0)
url += "?" + string.Join("&", q.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var (resp, text) = await _t.SendAsync(method, url, null, BuildHeaders(o), ct).ConfigureAwait(false);
var status = (int)resp.StatusCode;
if (!resp.IsSuccessStatusCode) throw Transport.ErrorFrom(status, text, resp.ReasonPhrase); // 206 is success
var r = new Response
{
Success = true,
Data = string.IsNullOrWhiteSpace(text) ? JsonDocument.Parse("null").RootElement.Clone() : JsonDocument.Parse(text).RootElement.Clone(),
};
if (list)
{
resp.Headers.TryGetValues("Content-Range", out var cr);
r.Metadata = MetadataFrom(cr?.FirstOrDefault(), o);
}
return r;
}
/// <summary>Single-record endpoint (SqlQuery). Data is the row object.</summary>
public Task<Response> QueryAsync(string path, IDictionary<string, object?>? p = null, FuncSpecOptions? o = null, HttpMethod? method = null, CancellationToken ct = default) =>
CallAsync(method ?? HttpMethod.Get, path, p, o, false, ct);
/// <summary>List endpoint (SqlQueryList). Metadata comes from Content-Range.</summary>
public Task<Response> QueryListAsync(string path, IDictionary<string, object?>? p = null, FuncSpecOptions? o = null, HttpMethod? method = null, CancellationToken ct = default) =>
CallAsync(method ?? HttpMethod.Get, path, p, o, true, ct);
}
+78
View File
@@ -0,0 +1,78 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace ResolveSpec;
/// <summary>Shared HTTP configuration for both clients.</summary>
public sealed class ClientOptions
{
public string? Token { get; set; }
public Dictionary<string, string> Headers { get; } = new(StringComparer.OrdinalIgnoreCase);
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Supply your own HttpClient (tests, pooling). Its BaseAddress is ignored.</summary>
public HttpClient? HttpClient { get; set; }
}
internal sealed class Transport
{
public readonly string BaseUrl;
readonly ClientOptions _o;
readonly HttpClient _http;
public Transport(string baseUrl, ClientOptions? o)
{
BaseUrl = baseUrl.TrimEnd('/');
_o = o ?? new ClientOptions();
_http = _o.HttpClient ?? new HttpClient { Timeout = _o.Timeout };
}
/// <summary>Content-Type &lt; custom headers &lt; per-call headers &lt; bearer token.</summary>
public async Task<(HttpResponseMessage resp, string body)> SendAsync(
HttpMethod method, string url, string? json, IDictionary<string, string>? extra, CancellationToken ct)
{
using var req = new HttpRequestMessage(method, url);
if (json != null) req.Content = new StringContent(json, Encoding.UTF8, "application/json");
foreach (var (k, v) in _o.Headers) Set(req, k, v);
if (extra != null) foreach (var (k, v) in extra) Set(req, k, v);
if (!string.IsNullOrEmpty(_o.Token)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _o.Token);
var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return (resp, body);
}
static void Set(HttpRequestMessage req, string name, string value)
{
req.Headers.Remove(name);
if (!req.Headers.TryAddWithoutValidation(name, value) && req.Content != null)
{
req.Content.Headers.Remove(name);
req.Content.Headers.TryAddWithoutValidation(name, value);
}
}
public static ResolveSpecException ErrorFrom(int status, string body, string? reason)
{
ApiError? err = null;
var isJson = false;
try
{
using var doc = JsonDocument.Parse(body);
isJson = true;
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.Object)
err = e.Deserialize<ApiError>();
}
catch (JsonException) { }
var message = err?.Message;
if (string.IsNullOrEmpty(message))
{
var text = isJson ? "" : body.Trim();
if (text.Length > 200) text = text[..200];
message = text.Length > 0 ? text : $"{reason ?? "Error"} ({status})";
}
return new ResolveSpecException(message, status, err);
}
public static string Segment(string s) => Uri.EscapeDataString(s);
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ResolveSpec</RootNamespace>
<PackageId>ResolveSpec.Client</PackageId>
<Version>0.1.0</Version>
<Description>Client for ResolveSpec (JSON body) and FunctionSpec endpoints</Description>
</PropertyGroup>
</Project>
@@ -0,0 +1,65 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ResolveSpec;
/// <summary>Client for the ResolveSpec JSON body protocol: POST {operation, data, options}.</summary>
public sealed class ResolveSpecClient
{
static readonly JsonSerializerOptions Json = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
readonly Transport _t;
public ResolveSpecClient(string baseUrl, ClientOptions? options = null) => _t = new Transport(baseUrl, options);
sealed class Request
{
[JsonPropertyName("operation")] public string Operation { get; set; } = "";
[JsonPropertyName("id")] public string[]? Id { get; set; }
[JsonPropertyName("data")] public object? Data { get; set; }
[JsonPropertyName("options")] public Options? Options { get; set; }
}
// A single id (int/long/string) goes in the URL; string[] / IEnumerable<string> goes in the body.
static string? UrlId(object? id) => id switch
{
null => null,
string s => s,
IEnumerable<string> => null,
_ => Convert.ToString(id, System.Globalization.CultureInfo.InvariantCulture),
};
static string[]? BodyId(object? id) => id is IEnumerable<string> e and not string ? e.ToArray() : null;
string Url(string schema, string entity, string? id)
{
var u = $"{_t.BaseUrl}/{Transport.Segment(schema)}/{Transport.Segment(entity)}";
return string.IsNullOrEmpty(id) ? u : $"{u}/{Transport.Segment(id)}";
}
async Task<Response> SendAsync(HttpMethod method, string url, Request? body, CancellationToken ct)
{
var json = body == null ? null : JsonSerializer.Serialize(body, Json);
var (resp, text) = await _t.SendAsync(method, url, json, null, ct).ConfigureAwait(false);
var status = (int)resp.StatusCode;
if (!resp.IsSuccessStatusCode) throw Transport.ErrorFrom(status, text, resp.ReasonPhrase);
var r = JsonSerializer.Deserialize<Response>(text, Json) ?? new Response();
if (!r.Success && r.Error != null) throw new ResolveSpecException(r.Error.Message, status, r.Error);
return r;
}
/// <summary>GET /{schema}/{entity}</summary>
public Task<Response> GetMetadataAsync(string schema, string entity, CancellationToken ct = default) =>
SendAsync(HttpMethod.Get, Url(schema, entity, null), null, ct);
public Task<Response> ReadAsync(string schema, string entity, object? id = null, Options? options = null, CancellationToken ct = default) =>
SendAsync(HttpMethod.Post, Url(schema, entity, UrlId(id)), new Request { Operation = "read", Id = BodyId(id), Options = options }, ct);
public Task<Response> CreateAsync(string schema, string entity, object data, Options? options = null, CancellationToken ct = default) =>
SendAsync(HttpMethod.Post, Url(schema, entity, null), new Request { Operation = "create", Data = data, Options = options }, ct);
public Task<Response> UpdateAsync(string schema, string entity, object data, object? id = null, Options? options = null, CancellationToken ct = default) =>
SendAsync(HttpMethod.Post, Url(schema, entity, UrlId(id)), new Request { Operation = "update", Id = BodyId(id), Data = data, Options = options }, ct);
public Task<Response> DeleteAsync(string schema, string entity, object id, CancellationToken ct = default) =>
SendAsync(HttpMethod.Post, Url(schema, entity, UrlId(id)), new Request { Operation = "delete" }, ct);
}
+137
View File
@@ -0,0 +1,137 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ResolveSpec;
// Types aligned with Go pkg/common/types.go. JsonPropertyName values are the wire names.
public sealed class FilterOption
{
[JsonPropertyName("column")] public string Column { get; set; } = "";
/// <summary>eq neq gt gte lt lte like ilike in contains startswith endswith between between_inclusive is_null is_not_null</summary>
[JsonPropertyName("operator")] public string Operator { get; set; } = "eq";
[JsonPropertyName("value")] public object? Value { get; set; }
/// <summary>AND | OR</summary>
[JsonPropertyName("logic_operator")] public string? LogicOperator { get; set; }
}
public sealed class SortOption
{
[JsonPropertyName("column")] public string Column { get; set; } = "";
/// <summary>asc | desc</summary>
[JsonPropertyName("direction")] public string Direction { get; set; } = "asc";
}
public sealed class Parameter
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("value")] public string Value { get; set; } = "";
[JsonPropertyName("sequence")] public int? Sequence { get; set; }
}
public sealed class CustomOperator
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("sql")] public string Sql { get; set; } = "";
}
public sealed class ComputedColumn
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("expression")] public string Expression { get; set; } = "";
}
public sealed class PreloadOption
{
[JsonPropertyName("relation")] public string? Relation { get; set; }
[JsonPropertyName("table_name")] public string? TableName { get; set; }
[JsonPropertyName("columns")] public List<string>? Columns { get; set; }
[JsonPropertyName("omit_columns")] public List<string>? OmitColumns { get; set; }
[JsonPropertyName("sort")] public List<SortOption>? Sort { get; set; }
[JsonPropertyName("filters")] public List<FilterOption>? Filters { get; set; }
[JsonPropertyName("where")] public string? Where { get; set; }
[JsonPropertyName("limit")] public int? Limit { get; set; }
[JsonPropertyName("offset")] public int? Offset { get; set; }
[JsonPropertyName("updateable")] public bool? Updateable { get; set; }
[JsonPropertyName("computed_ql")] public Dictionary<string, string>? ComputedQl { get; set; }
[JsonPropertyName("recursive")] public bool? Recursive { get; set; }
[JsonPropertyName("primary_key")] public string? PrimaryKey { get; set; }
[JsonPropertyName("related_key")] public string? RelatedKey { get; set; }
[JsonPropertyName("foreign_key")] public string? ForeignKey { get; set; }
[JsonPropertyName("recursive_child_key")] public string? RecursiveChildKey { get; set; }
[JsonPropertyName("sql_joins")] public List<string>? SqlJoins { get; set; }
[JsonPropertyName("join_aliases")] public List<string>? JoinAliases { get; set; }
}
public sealed class VectorSearchOption
{
[JsonPropertyName("column")] public string Column { get; set; } = "";
[JsonPropertyName("vector")] public List<double> Vector { get; set; } = new();
/// <summary>l2 (default) | cosine | ip</summary>
[JsonPropertyName("metric")] public string? Metric { get; set; }
/// <summary>Distance column alias, default _distance.</summary>
[JsonPropertyName("as")] public string? As { get; set; }
[JsonPropertyName("direction")] public string? Direction { get; set; }
}
/// <summary>ResolveSpec request options object.</summary>
public sealed class Options
{
[JsonPropertyName("preload")] public List<PreloadOption>? Preload { get; set; }
[JsonPropertyName("columns")] public List<string>? Columns { get; set; }
[JsonPropertyName("omit_columns")] public List<string>? OmitColumns { get; set; }
[JsonPropertyName("filters")] public List<FilterOption>? Filters { get; set; }
[JsonPropertyName("sort")] public List<SortOption>? Sort { get; set; }
[JsonPropertyName("limit")] public int? Limit { get; set; }
[JsonPropertyName("offset")] public int? Offset { get; set; }
[JsonPropertyName("customOperators")] public List<CustomOperator>? CustomOperators { get; set; }
[JsonPropertyName("computedColumns")] public List<ComputedColumn>? ComputedColumns { get; set; }
[JsonPropertyName("parameters")] public List<Parameter>? Parameters { get; set; }
[JsonPropertyName("cursor_forward")] public string? CursorForward { get; set; }
[JsonPropertyName("cursor_backward")] public string? CursorBackward { get; set; }
[JsonPropertyName("fetch_row_number")] public string? FetchRowNumber { get; set; }
[JsonPropertyName("vector_search")] public VectorSearchOption? VectorSearch { get; set; }
}
public sealed class Metadata
{
[JsonPropertyName("total")] public long Total { get; set; }
[JsonPropertyName("count")] public long Count { get; set; }
[JsonPropertyName("filtered")] public long Filtered { get; set; }
[JsonPropertyName("limit")] public long Limit { get; set; }
[JsonPropertyName("offset")] public long Offset { get; set; }
}
public sealed class ApiError
{
[JsonPropertyName("code")] public string Code { get; set; } = "";
[JsonPropertyName("message")] public string Message { get; set; } = "";
[JsonPropertyName("details")] public JsonElement? Details { get; set; }
/// <summary>Server-side reason (funcspec / restheadspec).</summary>
[JsonPropertyName("detail")] public string? Detail { get; set; }
[JsonPropertyName("sql")] public string? Sql { get; set; }
}
/// <summary>ResolveSpec envelope. <see cref="Data"/> is raw JSON; use <see cref="Decode{T}"/>.</summary>
public sealed class Response
{
[JsonPropertyName("success")] public bool Success { get; set; }
[JsonPropertyName("data")] public JsonElement Data { get; set; }
[JsonPropertyName("metadata")] public Metadata? Metadata { get; set; }
[JsonPropertyName("error")] public ApiError? Error { get; set; }
public T? Decode<T>() => Data.ValueKind == JsonValueKind.Undefined ? default : Data.Deserialize<T>();
}
/// <summary>Thrown on a non-2xx response or an unsuccessful API result.</summary>
public sealed class ResolveSpecException : Exception
{
public int StatusCode { get; }
public ApiError Error { get; }
public ResolveSpecException(string message, int statusCode, ApiError? error = null) : base(message)
{
StatusCode = statusCode;
Error = error ?? new ApiError { Message = message };
}
}
+178
View File
@@ -0,0 +1,178 @@
using System.Net;
using System.Text;
using System.Text.Json;
using ResolveSpec;
using Xunit;
public class Stub : HttpMessageHandler
{
public HttpRequestMessage? Request;
public string Body = "";
readonly HttpStatusCode _status;
readonly string _json;
readonly Dictionary<string, string> _headers;
public Stub(HttpStatusCode status, string json, Dictionary<string, string>? headers = null)
{
_status = status; _json = json; _headers = headers ?? new();
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Request = request;
Body = request.Content == null ? "" : await request.Content.ReadAsStringAsync(ct);
var r = new HttpResponseMessage(_status) { Content = new StringContent(_json, Encoding.UTF8, "application/json") };
foreach (var (k, v) in _headers) r.Headers.TryAddWithoutValidation(k, v);
return r;
}
}
public class ResolveSpecTests
{
static (ResolveSpecClient, Stub) Make(HttpStatusCode s, string json)
{
var stub = new Stub(s, json);
var o = new ClientOptions { Token = "tok", HttpClient = new HttpClient(stub) };
o.Headers["X-Tenant"] = "a";
return (new ResolveSpecClient("http://localhost:3000/", o), stub);
}
[Fact]
public async Task ReadPostsBody()
{
var (c, s) = Make(HttpStatusCode.OK, """{"success":true,"data":[{"id":1}]}""");
var r = await c.ReadAsync("public", "users", null, new Options { Limit = 5, Filters = new() { new FilterOption { Column = "a", Operator = "eq", Value = 1 } } });
Assert.Equal(HttpMethod.Post, s.Request!.Method);
Assert.Equal("/public/users", s.Request.RequestUri!.AbsolutePath);
Assert.Equal("Bearer tok", s.Request.Headers.Authorization!.ToString());
Assert.Equal("a", s.Request.Headers.GetValues("X-Tenant").Single());
using var body = JsonDocument.Parse(s.Body);
Assert.Equal("read", body.RootElement.GetProperty("operation").GetString());
Assert.Equal(5, body.RootElement.GetProperty("options").GetProperty("limit").GetInt32());
Assert.False(body.RootElement.TryGetProperty("id", out _));
Assert.Single(r.Decode<List<Dictionary<string, int>>>()!);
}
[Fact]
public async Task IdPlacement()
{
var (c, s) = Make(HttpStatusCode.OK, """{"success":true,"data":{}}""");
await c.ReadAsync("s", "e", 7);
Assert.Equal("/s/e/7", s.Request!.RequestUri!.AbsolutePath);
await c.UpdateAsync("s", "e", new { a = 1 }, new[] { "1", "2" });
Assert.Equal("/s/e", s.Request!.RequestUri!.AbsolutePath);
using (var b = JsonDocument.Parse(s.Body))
{
Assert.Equal(2, b.RootElement.GetProperty("id").GetArrayLength());
Assert.Equal("update", b.RootElement.GetProperty("operation").GetString());
}
await c.DeleteAsync("s", "e", "a/b");
Assert.Equal("/s/e/a%2Fb", s.Request!.RequestUri!.AbsoluteUri[(s.Request.RequestUri.AbsoluteUri.IndexOf("/s/e", StringComparison.Ordinal))..]);
Assert.Contains("\"delete\"", s.Body);
}
[Fact]
public async Task Errors()
{
var (c, _) = Make(HttpStatusCode.BadRequest, """{"success":false,"error":{"code":"x","message":"bad","detail":"why"}}""");
var e = await Assert.ThrowsAsync<ResolveSpecException>(() => c.ReadAsync("s", "e"));
Assert.Equal((400, "x", "bad", "why"), (e.StatusCode, e.Error.Code, e.Message, e.Error.Detail));
var (c2, _) = Make(HttpStatusCode.BadGateway, "bad gateway");
var e2 = await Assert.ThrowsAsync<ResolveSpecException>(() => c2.ReadAsync("s", "e"));
Assert.Equal((502, "bad gateway"), (e2.StatusCode, e2.Message));
var (c3, _) = Make(HttpStatusCode.OK, """{"success":false,"error":{"code":"c","message":"nope"}}""");
var e3 = await Assert.ThrowsAsync<ResolveSpecException>(() => c3.ReadAsync("s", "e"));
Assert.Equal("nope", e3.Message);
}
}
public class FuncSpecTests
{
[Fact]
public void HeaderFilters()
{
var h = FuncSpecClient.BuildHeaders(new FuncSpecOptions
{
Filters = new()
{
new() { Column = "status", Operator = "eq", Value = "active" },
new() { Column = "age", Operator = "gte", Value = 18 },
new() { Column = "name", Operator = "contains", Value = "x", LogicOperator = "OR" },
new() { Column = "deleted", Operator = "is_null" },
new() { Column = "id", Operator = "in", Value = new[] { 1, 2 } },
new() { Column = "p", Operator = "between_inclusive", Value = new[] { 1, 5 } },
},
});
Assert.Equal(new Dictionary<string, string>
{
["X-FieldFilter-status"] = "active",
["X-SearchOp-greaterthanorequal-age"] = "18",
["X-SearchOr-contains-name"] = "x",
["X-SearchOp-empty-deleted"] = "",
["X-SearchOp-in-id"] = "1,2",
["X-SearchOp-betweeninclusive-p"] = "1,5",
}, h);
}
[Fact]
public void HeaderMiscAndEncoding()
{
var h = FuncSpecClient.BuildHeaders(new FuncSpecOptions
{
SearchFilters = new() { ["name"] = "bob" }, CustomSqlWhere = "a = 1", CustomSqlOr = "b = 2",
Sort = new() { new() { Column = "name", Direction = "asc" }, new() { Column = "created_at", Direction = "DESC" } },
Limit = 5, Offset = 10, Distinct = true, SkipCount = true, SkipCache = false, ResponseFormat = "syncfusion",
});
Assert.Equal("name ASC,created_at DESC", h["X-Sort"]);
Assert.Equal("bob", h["X-SearchFilter-name"]);
Assert.Equal("a = 1", h["X-Custom-SQL-W"]);
Assert.Equal("false", h["X-SkipCache"]);
Assert.Equal("true", h["X-Syncfusion"]);
h = FuncSpecClient.BuildHeaders(new FuncSpecOptions { Filters = new()
{
new() { Column = "n", Operator = "eq", Value = "héllo" },
new() { Column = "m", Operator = "eq", Value = " pad" },
} });
Assert.StartsWith("ZIP_", h["X-FieldFilter-n"]);
Assert.Equal("héllo", FuncSpecClient.DecodeHeaderValue(h["X-FieldFilter-n"]));
Assert.Equal(" pad", FuncSpecClient.DecodeHeaderValue(h["X-FieldFilter-m"]));
}
[Fact]
public void QueryBuilding()
{
var q = FuncSpecClient.BuildQuery(new Dictionary<string, object?> { ["a"] = true, ["b"] = new[] { "x", "y" }, ["c"] = null, ["d"] = 3 });
Assert.Equal(new[] { "a=true", "b=x", "b=y", "d=3" }, q.Select(kv => $"{kv.Key}={kv.Value}"));
}
[Fact]
public async Task QueryListMetadata()
{
var stub = new Stub((HttpStatusCode)206, """[{"id":1},{"id":2}]""", new() { ["Content-Range"] = "items 10-12/50" });
var c = new FuncSpecClient("http://x", new ClientOptions { Token = "tok", HttpClient = new HttpClient(stub) });
var r = await c.QueryListAsync("/api/users", new Dictionary<string, object?> { ["org"] = 1 }, new FuncSpecOptions { Limit = 2 });
Assert.Equal("GET", stub.Request!.Method.Method);
Assert.Equal("/api/users", stub.Request.RequestUri!.AbsolutePath);
Assert.Equal("?org=1", stub.Request.RequestUri.Query);
Assert.Equal("2", stub.Request.Headers.GetValues("X-Limit").Single());
Assert.Equal((50L, 2L, 50L, 2L, 10L), (r.Metadata!.Total, r.Metadata.Count, r.Metadata.Filtered, r.Metadata.Limit, r.Metadata.Offset));
Assert.Equal(2, r.Data.GetArrayLength());
}
[Fact]
public async Task QuerySingleAndError()
{
var ok = new FuncSpecClient("http://x", new ClientOptions { HttpClient = new HttpClient(new Stub(HttpStatusCode.OK, """{"id":1}""")) });
var r = await ok.QueryAsync("api/u");
Assert.Null(r.Metadata);
Assert.Equal(1, r.Data.GetProperty("id").GetInt32());
var bad = new FuncSpecClient("http://x", new ClientOptions { HttpClient = new HttpClient(new Stub(HttpStatusCode.BadRequest,
"""{"success":false,"error":{"code":"hook_error","message":"Hook execution failed","detail":"authentication required"}}""")) });
var e = await Assert.ThrowsAsync<ResolveSpecException>(() => bad.QueryAsync("api/u"));
Assert.Equal(("hook_error", "authentication required"), (e.Error.Code, e.Error.Detail));
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../src/ResolveSpec.csproj" />
</ItemGroup>
</Project>
+3
View File
@@ -0,0 +1,3 @@
.dart_tool/
pubspec.lock
build/
+43
View File
@@ -0,0 +1,43 @@
# resolvespec (Dart)
Dart / Flutter client for ResolveSpec (JSON body) and FunctionSpec. Depends on `package:http`. Dart >= 3.3.
> Not compiled or tested yet (no Dart SDK was available). Run `dart pub get && dart test` first.
## Clients
| Type | Constructor | Methods |
|---|---|---|
| `ResolveSpecClient` | `(baseUrl, [ClientOptions])` | `getMetadata` `read` `create` `update` `delete` `close` |
| `FuncSpecClient` | `(baseUrl, [ClientOptions])` | `query` `queryList` `close` |
`ClientOptions(token:, headers:, timeout:, httpClient:)`. Precedence: Content-Type < custom headers < bearer token.
## ResolveSpec
- `id`: `int`/`String` → URL, `List<String>` → body. Named args: `id:`, `options:`.
- `Options`, `FilterOption(column, operator, [value, logic])`, `SortOption(column, [direction])`.
- Result: `Response{success, data (decoded JSON), metadata}`.
## FunctionSpec
- Routes are server-defined: pass the `path`.
- `params:` map → query string (list → repeated keys, null skipped).
- `FuncSpecOptions` → `X-*` headers: `filters`, `searchFilters`, `customSqlWhere`, `customSqlOr`, `sort`, `limit`, `offset`, `distinct`, `skipCount`, `skipCache`, `responseFormat`.
- `queryList` fills `metadata` from `Content-Range`; 206 is success.
- Helpers: `buildHeaders`, `buildQuery`, `encodeHeaderValue`, `decodeHeaderValue`.
## Server quirks
- `sort` is raw SQL in ORDER BY (client sends `col ASC|DESC`).
- One search operator per column.
- Values starting `ZIP_` / `__` are base64-decoded by the server.
- Non-ASCII, control chars and edge spaces are auto-encoded (`ZIP_`).
## Errors
`ResolveSpecException{statusCode, message, error: ApiError{code, detail, sql}}`.
## Test
`dart test`
@@ -0,0 +1 @@
include: package:lints/recommended.yaml
@@ -0,0 +1,7 @@
/// Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
library;
export 'src/client.dart' show ClientOptions, ResolveSpecException;
export 'src/funcspec.dart';
export 'src/resolvespec.dart';
export 'src/types.dart';
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'types.dart';
/// Thrown on a non-2xx response or an unsuccessful API result.
class ResolveSpecException implements Exception {
final int statusCode;
final String message;
final ApiError error;
ResolveSpecException(this.message, this.statusCode, [ApiError? error]) : error = error ?? ApiError(message: message);
@override
String toString() => 'ResolveSpecException($statusCode): $message';
}
/// Shared HTTP configuration for both clients.
class ClientOptions {
final String? token;
final Map<String, String> headers;
final Duration timeout;
/// Supply your own client (tests, pooling).
final http.Client? httpClient;
const ClientOptions({this.token, this.headers = const {}, this.timeout = const Duration(seconds: 30), this.httpClient});
}
class Transport {
final String baseUrl;
final ClientOptions options;
final http.Client _http;
Transport(String baseUrl, ClientOptions? options)
: baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), ''),
options = options ?? const ClientOptions(),
_http = options?.httpClient ?? http.Client();
/// Content-Type < custom headers < per-call headers < bearer token.
Future<http.Response> send(String method, Uri uri, {String? body, Map<String, String>? extra}) {
final headers = <String, String>{'Content-Type': 'application/json'};
void merge(Map<String, String> src) {
for (final e in src.entries) {
headers.removeWhere((k, _) => k.toLowerCase() == e.key.toLowerCase());
headers[e.key] = e.value;
}
}
merge(options.headers);
if (extra != null) merge(extra);
final token = options.token;
if (token != null && token.isNotEmpty) merge({'Authorization': 'Bearer $token'});
final req = http.Request(method, uri)..headers.addAll(headers);
if (body != null) req.body = body;
return _http.send(req).timeout(options.timeout).then(http.Response.fromStream);
}
void close() => _http.close();
static ResolveSpecException errorFrom(http.Response resp) {
final body = utf8.decode(resp.bodyBytes, allowMalformed: true);
ApiError? err;
var isJson = false;
try {
final parsed = jsonDecode(body);
isJson = true;
if (parsed is Map<String, dynamic> && parsed['error'] is Map<String, dynamic>) {
err = ApiError.fromJson(parsed['error'] as Map<String, dynamic>);
}
} on FormatException {
// not JSON
}
var message = err?.message ?? '';
if (message.isEmpty) {
var text = isJson ? '' : body.trim();
if (text.length > 200) text = text.substring(0, 200);
message = text.isNotEmpty ? text : '${resp.reasonPhrase ?? 'Error'} (${resp.statusCode})';
}
return ResolveSpecException(message, resp.statusCode, err);
}
}
@@ -0,0 +1,186 @@
import 'dart:convert';
import 'client.dart';
import 'types.dart';
/// 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.
class FuncSpecOptions {
/// eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr.
final List<FilterOption>? filters;
/// X-SearchFilter-{col}: text ILIKE.
final Map<String, String>? searchFilters;
final String? customSqlWhere;
final String? customSqlOr;
final List<SortOption>? sort;
final int? limit;
final int? offset;
final bool? distinct;
final bool? skipCount;
final bool? skipCache;
/// simple | detail | syncfusion
final String? responseFormat;
const FuncSpecOptions({
this.filters,
this.searchFilters,
this.customSqlWhere,
this.customSqlOr,
this.sort,
this.limit,
this.offset,
this.distinct,
this.skipCount,
this.skipCache,
this.responseFormat,
});
}
const _operatorMap = {
'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',
};
String _scalar(Object? v) => v == null ? '' : v.toString();
String _filterValue(Object? v) => v is Iterable ? v.map(_scalar).join(',') : _scalar(v);
/// Base64 (UTF-8) with the `ZIP_` prefix.
String encodeHeaderValue(String v) => 'ZIP_${base64.encode(utf8.encode(v))}';
/// Decode a value that may carry a `ZIP_` or `__` prefix (nested allowed).
String decodeHeaderValue(String v) {
for (final p in const ['ZIP_', '__']) {
if (v.startsWith(p)) {
var b64 = v.substring(p.length).replaceAll(RegExp(r'[\n\r ]'), '');
b64 = b64.padRight(b64.length + (4 - b64.length % 4) % 4, '=');
try {
return decodeHeaderValue(utf8.decode(base64.decode(b64)));
} on FormatException {
return v;
}
}
}
return v;
}
/// Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).
String _safe(String v) {
final unsafe = v != v.trim() || v.runes.any((c) => c > 127 || c < 32 || c == 127);
return unsafe ? encodeHeaderValue(v) : v;
}
/// Build the X-* headers understood by funcspec.ParseParameters.
Map<String, String> buildHeaders(FuncSpecOptions? o) {
final h = <String, String>{};
if (o == null) return h;
for (final f in o.filters ?? const <FilterOption>[]) {
final logic = f.logicOperator ?? 'AND';
final v = _safe(_filterValue(f.value));
if (f.operator == 'eq' && logic == 'AND') {
h['X-FieldFilter-${f.column}'] = v;
} else {
final kind = logic == 'OR' ? 'X-SearchOr' : 'X-SearchOp';
h['$kind-${_operatorMap[f.operator] ?? f.operator}-${f.column}'] = v;
}
}
o.searchFilters?.forEach((col, text) => h['X-SearchFilter-$col'] = _safe(text));
if (o.customSqlWhere != null && o.customSqlWhere!.isNotEmpty) h['X-Custom-SQL-W'] = _safe(o.customSqlWhere!);
if (o.customSqlOr != null && o.customSqlOr!.isNotEmpty) h['X-Custom-SQL-Or'] = _safe(o.customSqlOr!);
if (o.sort != null && o.sort!.isNotEmpty) {
// funcspec puts this verbatim into ORDER BY
h['X-Sort'] = _safe(o.sort!.map((s) => '${s.column} ${s.direction.toLowerCase() == 'desc' ? 'DESC' : 'ASC'}').join(','));
}
if (o.limit != null) h['X-Limit'] = '${o.limit}';
if (o.offset != null) h['X-Offset'] = '${o.offset}';
if (o.distinct != null) h['X-Distinct'] = '${o.distinct}';
if (o.skipCount != null) h['X-SkipCount'] = '${o.skipCount}';
if (o.skipCache != null) h['X-SkipCache'] = '${o.skipCache}';
switch (o.responseFormat) {
case 'simple':
h['X-SimpleApi'] = 'true';
case 'detail':
h['X-DetailApi'] = 'true';
case 'syncfusion':
h['X-Syncfusion'] = 'true';
}
return h;
}
/// Build query-string pairs: lists -> repeated keys, null skipped, bools -> true/false.
Map<String, List<String>> buildQuery(Map<String, Object?>? params) {
final out = <String, List<String>>{};
params?.forEach((k, v) {
if (v == null) return;
out[k] = v is Iterable ? v.map((e) => _safe(_scalar(e))).toList() : [_safe(_scalar(v))];
});
return out;
}
final _contentRange = RegExp(r'(\d+)-(\d+)/(\d+)');
Metadata _metadata(String? contentRange, FuncSpecOptions? o) {
final m = _contentRange.firstMatch(contentRange ?? '');
if (m == null) return Metadata(limit: o?.limit ?? 0);
final start = int.parse(m.group(1)!);
final end = int.parse(m.group(2)!);
final total = int.parse(m.group(3)!);
return Metadata(total: total, count: end - start, filtered: total, limit: o?.limit ?? 0, offset: start);
}
/// Client for user-defined SQL endpoints. Routes are defined by the server application.
class FuncSpecClient {
final Transport _t;
FuncSpecClient(String baseUrl, [ClientOptions? options]) : _t = Transport(baseUrl, options);
void close() => _t.close();
Future<Response> _call(String method, String path, Map<String, Object?>? params, FuncSpecOptions? o, bool list) async {
final base = Uri.parse('${_t.baseUrl}/${path.replaceAll(RegExp(r'^/+'), '')}');
final pairs = <String>[];
buildQuery(params).forEach((k, vs) {
for (final v in vs) {
pairs.add('${Uri.encodeQueryComponent(k)}=${Uri.encodeQueryComponent(v)}');
}
});
final uri = pairs.isEmpty ? base : base.replace(query: pairs.join('&'));
final resp = await _t.send(method, uri, extra: buildHeaders(o));
if (resp.statusCode < 200 || resp.statusCode > 299) throw Transport.errorFrom(resp); // 206 is success
final text = utf8.decode(resp.bodyBytes);
return Response(
success: true,
data: text.trim().isEmpty ? null : jsonDecode(text),
metadata: list ? _metadata(resp.headers['content-range'], o) : null,
);
}
/// Single-record endpoint (SqlQuery). `data` is the row object.
Future<Response> query(String path, {Map<String, Object?>? params, FuncSpecOptions? options, String method = 'GET'}) =>
_call(method.toUpperCase(), path, params, options, false);
/// List endpoint (SqlQueryList). Metadata comes from Content-Range.
Future<Response> queryList(String path, {Map<String, Object?>? params, FuncSpecOptions? options, String method = 'GET'}) =>
_call(method.toUpperCase(), path, params, options, true);
}
@@ -0,0 +1,63 @@
import 'dart:convert';
import 'client.dart';
import 'types.dart';
/// Client for the ResolveSpec JSON body protocol: POST {operation, data, options}.
///
/// A record `id` of type `int` or `String` goes in the URL; a `List<String>` goes in the body.
class ResolveSpecClient {
final Transport _t;
ResolveSpecClient(String baseUrl, [ClientOptions? options]) : _t = Transport(baseUrl, options);
void close() => _t.close();
static String? _urlId(Object? id) => id == null || id is List ? null : id.toString();
static List<String>? _bodyId(Object? id) => id is List ? id.map((e) => e.toString()).toList() : null;
Uri _url(String schema, String entity, String? id) {
var u = '${_t.baseUrl}/${Uri.encodeComponent(schema)}/${Uri.encodeComponent(entity)}';
if (id != null && id.isNotEmpty) u += '/${Uri.encodeComponent(id)}';
return Uri.parse(u);
}
Future<Response> _send(String method, Uri url, Map<String, dynamic>? body) async {
final resp = await _t.send(method, url, body: body == null ? null : jsonEncode(body));
if (resp.statusCode < 200 || resp.statusCode > 299) throw Transport.errorFrom(resp);
final decoded = jsonDecode(utf8.decode(resp.bodyBytes));
final r = Response.fromJson(decoded as Map<String, dynamic>);
if (!r.success && r.error != null) throw ResolveSpecException(r.error!.message, resp.statusCode, r.error);
return r;
}
/// GET /{schema}/{entity}
Future<Response> getMetadata(String schema, String entity) => _send('GET', _url(schema, entity, null), null);
Future<Response> read(String schema, String entity, {Object? id, Options? options}) => _send(
'POST',
_url(schema, entity, _urlId(id)),
{'operation': 'read', if (_bodyId(id) != null) 'id': _bodyId(id), if (options != null) 'options': options.toJson()},
);
Future<Response> create(String schema, String entity, Object data, {Options? options}) => _send(
'POST',
_url(schema, entity, null),
{'operation': 'create', 'data': data, if (options != null) 'options': options.toJson()},
);
Future<Response> update(String schema, String entity, Object data, {Object? id, Options? options}) => _send(
'POST',
_url(schema, entity, _urlId(id)),
{
'operation': 'update',
if (_bodyId(id) != null) 'id': _bodyId(id),
'data': data,
if (options != null) 'options': options.toJson(),
},
);
Future<Response> delete(String schema, String entity, Object id) =>
_send('POST', _url(schema, entity, _urlId(id)), {'operation': 'delete'});
}
+268
View File
@@ -0,0 +1,268 @@
// Types aligned with Go pkg/common/types.go. toJson() emits the wire names.
Map<String, dynamic> _compact(Map<String, dynamic> m) {
m.removeWhere((_, v) => v == null);
return m;
}
class FilterOption {
final String column;
/// eq neq gt gte lt lte like ilike in contains startswith endswith between
/// between_inclusive is_null is_not_null
final String operator;
final Object? value;
/// AND | OR
final String? logicOperator;
const FilterOption(this.column, this.operator, [this.value, this.logicOperator]);
Map<String, dynamic> toJson() => _compact({
'column': column,
'operator': operator,
'value': value,
'logic_operator': logicOperator,
});
}
class SortOption {
final String column;
/// asc | desc
final String direction;
const SortOption(this.column, [this.direction = 'asc']);
Map<String, dynamic> toJson() => {'column': column, 'direction': direction};
}
class Parameter {
final String name;
final String value;
final int? sequence;
const Parameter(this.name, this.value, [this.sequence]);
Map<String, dynamic> toJson() => _compact({'name': name, 'value': value, 'sequence': sequence});
}
class CustomOperator {
final String name;
final String sql;
const CustomOperator(this.name, this.sql);
Map<String, dynamic> toJson() => {'name': name, 'sql': sql};
}
class ComputedColumn {
final String name;
final String expression;
const ComputedColumn(this.name, this.expression);
Map<String, dynamic> toJson() => {'name': name, 'expression': expression};
}
class PreloadOption {
final String? relation;
final String? tableName;
final List<String>? columns;
final List<String>? omitColumns;
final List<SortOption>? sort;
final List<FilterOption>? filters;
final String? where;
final int? limit;
final int? offset;
final bool? updateable;
final Map<String, String>? computedQl;
final bool? recursive;
final String? primaryKey;
final String? relatedKey;
final String? foreignKey;
final String? recursiveChildKey;
final List<String>? sqlJoins;
final List<String>? joinAliases;
const PreloadOption({
this.relation,
this.tableName,
this.columns,
this.omitColumns,
this.sort,
this.filters,
this.where,
this.limit,
this.offset,
this.updateable,
this.computedQl,
this.recursive,
this.primaryKey,
this.relatedKey,
this.foreignKey,
this.recursiveChildKey,
this.sqlJoins,
this.joinAliases,
});
Map<String, dynamic> toJson() => _compact({
'relation': relation,
'table_name': tableName,
'columns': columns,
'omit_columns': omitColumns,
'sort': sort?.map((e) => e.toJson()).toList(),
'filters': filters?.map((e) => e.toJson()).toList(),
'where': where,
'limit': limit,
'offset': offset,
'updateable': updateable,
'computed_ql': computedQl,
'recursive': recursive,
'primary_key': primaryKey,
'related_key': relatedKey,
'foreign_key': foreignKey,
'recursive_child_key': recursiveChildKey,
'sql_joins': sqlJoins,
'join_aliases': joinAliases,
});
}
class VectorSearchOption {
final String column;
final List<double> vector;
/// l2 (default) | cosine | ip
final String? metric;
/// Distance column alias, default _distance.
final String? as;
final String? direction;
const VectorSearchOption(this.column, this.vector, {this.metric, this.as, this.direction});
Map<String, dynamic> toJson() =>
_compact({'column': column, 'vector': vector, 'metric': metric, 'as': as, 'direction': direction});
}
/// ResolveSpec request options object.
class Options {
final List<PreloadOption>? preload;
final List<String>? columns;
final List<String>? omitColumns;
final List<FilterOption>? filters;
final List<SortOption>? sort;
final int? limit;
final int? offset;
final List<CustomOperator>? customOperators;
final List<ComputedColumn>? computedColumns;
final List<Parameter>? parameters;
final String? cursorForward;
final String? cursorBackward;
final String? fetchRowNumber;
final VectorSearchOption? vectorSearch;
const Options({
this.preload,
this.columns,
this.omitColumns,
this.filters,
this.sort,
this.limit,
this.offset,
this.customOperators,
this.computedColumns,
this.parameters,
this.cursorForward,
this.cursorBackward,
this.fetchRowNumber,
this.vectorSearch,
});
Map<String, dynamic> toJson() => _compact({
'preload': preload?.map((e) => e.toJson()).toList(),
'columns': columns,
'omit_columns': omitColumns,
'filters': filters?.map((e) => e.toJson()).toList(),
'sort': sort?.map((e) => e.toJson()).toList(),
'limit': limit,
'offset': offset,
'customOperators': customOperators?.map((e) => e.toJson()).toList(),
'computedColumns': computedColumns?.map((e) => e.toJson()).toList(),
'parameters': parameters?.map((e) => e.toJson()).toList(),
'cursor_forward': cursorForward,
'cursor_backward': cursorBackward,
'fetch_row_number': fetchRowNumber,
'vector_search': vectorSearch?.toJson(),
});
}
class Metadata {
final int total;
final int count;
final int filtered;
final int limit;
final int offset;
const Metadata({this.total = 0, this.count = 0, this.filtered = 0, this.limit = 0, this.offset = 0});
factory Metadata.fromJson(Map<String, dynamic> j) => Metadata(
total: (j['total'] as num?)?.toInt() ?? 0,
count: (j['count'] as num?)?.toInt() ?? 0,
filtered: (j['filtered'] as num?)?.toInt() ?? 0,
limit: (j['limit'] as num?)?.toInt() ?? 0,
offset: (j['offset'] as num?)?.toInt() ?? 0,
);
@override
bool operator ==(Object other) =>
other is Metadata &&
other.total == total &&
other.count == count &&
other.filtered == filtered &&
other.limit == limit &&
other.offset == offset;
@override
int get hashCode => Object.hash(total, count, filtered, limit, offset);
@override
String toString() => 'Metadata(total: $total, count: $count, filtered: $filtered, limit: $limit, offset: $offset)';
}
class ApiError {
final String code;
final String message;
final Object? details;
/// Server-side reason (funcspec / restheadspec).
final String? detail;
final String? sql;
const ApiError({this.code = '', this.message = '', this.details, this.detail, this.sql});
factory ApiError.fromJson(Map<String, dynamic> j) => ApiError(
code: (j['code'] as String?) ?? '',
message: (j['message'] as String?) ?? '',
details: j['details'],
detail: j['detail'] as String?,
sql: j['sql'] as String?,
);
}
/// ResolveSpec envelope. [data] is the decoded JSON value (Map, List or scalar).
class Response {
final bool success;
final Object? data;
final Metadata? metadata;
final ApiError? error;
const Response({required this.success, this.data, this.metadata, this.error});
factory Response.fromJson(Map<String, dynamic> j) => Response(
success: j['success'] == true,
data: j['data'],
metadata: j['metadata'] is Map<String, dynamic> ? Metadata.fromJson(j['metadata'] as Map<String, dynamic>) : null,
error: j['error'] is Map<String, dynamic> ? ApiError.fromJson(j['error'] as Map<String, dynamic>) : null,
);
}
+14
View File
@@ -0,0 +1,14 @@
name: resolvespec
description: Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.3.0 <4.0.0"
dependencies:
http: ^1.2.0
dev_dependencies:
lints: ^4.0.0
test: ^1.25.0
@@ -0,0 +1,179 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:resolvespec/resolvespec.dart';
import 'package:test/test.dart';
(http.Client, List<http.Request>) stub(int status, Object body, {Map<String, String> headers = const {}}) {
final seen = <http.Request>[];
final client = MockClient((req) async {
seen.add(req);
final text = body is String ? body : jsonEncode(body);
return http.Response(text, status, headers: {'content-type': 'application/json', ...headers});
});
return (client, seen);
}
void main() {
group('resolvespec', () {
test('read posts body with headers', () async {
final (c, seen) = stub(200, {'success': true, 'data': [{'id': 1}]});
final client = ResolveSpecClient(
'http://localhost:3000/',
ClientOptions(token: 'tok', headers: {'X-Tenant': 'a'}, httpClient: c),
);
final r = await client.read('public', 'users',
options: const Options(limit: 5, filters: [FilterOption('a', 'eq', 1)]));
final req = seen.single;
expect(req.method, 'POST');
expect(req.url.path, '/public/users');
expect(req.headers['authorization'], 'Bearer tok');
expect(req.headers['x-tenant'], 'a');
final body = jsonDecode(req.body) as Map<String, dynamic>;
expect(body['operation'], 'read');
expect(body['options']['limit'], 5);
expect(body.containsKey('id'), isFalse);
expect((r.data as List).length, 1);
});
test('id placement', () async {
final (c, seen) = stub(200, {'success': true, 'data': {}});
final client = ResolveSpecClient('http://x', ClientOptions(httpClient: c));
await client.read('s', 'e', id: 7);
expect(seen.last.url.path, '/s/e/7');
await client.update('s', 'e', {'a': 1}, id: ['1', '2']);
expect(seen.last.url.path, '/s/e');
final b = jsonDecode(seen.last.body) as Map<String, dynamic>;
expect(b['id'], ['1', '2']);
expect(b['operation'], 'update');
await client.delete('s', 'e', 'a/b');
expect(seen.last.url.toString(), 'http://x/s/e/a%2Fb');
expect(jsonDecode(seen.last.body)['operation'], 'delete');
});
test('errors', () async {
final client = ResolveSpecClient(
'http://x',
ClientOptions(
httpClient: stub(400, {
'success': false,
'error': {'code': 'x', 'message': 'bad', 'detail': 'why'}
}).$1),
);
await expectLater(
client.read('s', 'e'),
throwsA(isA<ResolveSpecException>()
.having((e) => e.statusCode, 'status', 400)
.having((e) => e.error.code, 'code', 'x')
.having((e) => e.message, 'message', 'bad')
.having((e) => e.error.detail, 'detail', 'why')),
);
final plain = ResolveSpecClient('http://x', ClientOptions(httpClient: stub(502, 'bad gateway').$1));
await expectLater(
plain.read('s', 'e'),
throwsA(isA<ResolveSpecException>().having((e) => e.message, 'message', 'bad gateway')),
);
final soft = ResolveSpecClient(
'http://x',
ClientOptions(
httpClient: stub(200, {
'success': false,
'error': {'code': 'c', 'message': 'nope'}
}).$1),
);
await expectLater(soft.read('s', 'e'), throwsA(isA<ResolveSpecException>().having((e) => e.message, 'message', 'nope')));
});
});
group('funcspec', () {
test('header filters', () {
final h = buildHeaders(const FuncSpecOptions(filters: [
FilterOption('status', 'eq', 'active'),
FilterOption('age', 'gte', 18),
FilterOption('name', 'contains', 'x', 'OR'),
FilterOption('deleted', 'is_null'),
FilterOption('id', 'in', [1, 2]),
FilterOption('p', 'between_inclusive', [1, 5]),
]));
expect(h, {
'X-FieldFilter-status': 'active',
'X-SearchOp-greaterthanorequal-age': '18',
'X-SearchOr-contains-name': 'x',
'X-SearchOp-empty-deleted': '',
'X-SearchOp-in-id': '1,2',
'X-SearchOp-betweeninclusive-p': '1,5',
});
});
test('misc headers and encoding', () {
var h = buildHeaders(const FuncSpecOptions(
searchFilters: {'name': 'bob'},
customSqlWhere: 'a = 1',
customSqlOr: 'b = 2',
sort: [SortOption('name', 'asc'), SortOption('created_at', 'DESC')],
limit: 5,
offset: 10,
distinct: true,
skipCount: true,
skipCache: false,
responseFormat: 'syncfusion',
));
expect(h['X-Sort'], 'name ASC,created_at DESC');
expect(h['X-SearchFilter-name'], 'bob');
expect(h['X-Custom-SQL-W'], 'a = 1');
expect(h['X-SkipCache'], 'false');
expect(h['X-Syncfusion'], 'true');
h = buildHeaders(const FuncSpecOptions(filters: [
FilterOption('n', 'eq', 'héllo'),
FilterOption('m', 'eq', ' pad'),
]));
expect(h['X-FieldFilter-n'], startsWith('ZIP_'));
expect(decodeHeaderValue(h['X-FieldFilter-n']!), 'héllo');
expect(decodeHeaderValue(h['X-FieldFilter-m']!), ' pad');
});
test('query building', () {
expect(buildQuery({'a': true, 'b': ['x', 'y'], 'c': null, 'd': 3}), {
'a': ['true'],
'b': ['x', 'y'],
'd': ['3'],
});
});
test('queryList metadata', () async {
final (c, seen) = stub(206, [{'id': 1}, {'id': 2}], headers: {'Content-Range': 'items 10-12/50'});
final client = FuncSpecClient('http://x', ClientOptions(token: 'tok', httpClient: c));
final r = await client.queryList('/api/users', params: {'org': 1}, options: const FuncSpecOptions(limit: 2));
expect(seen.single.method, 'GET');
expect(seen.single.url.path, '/api/users');
expect(seen.single.url.query, 'org=1');
expect(seen.single.headers['x-limit'], '2');
expect(r.metadata, const Metadata(total: 50, count: 2, filtered: 50, limit: 2, offset: 10));
expect((r.data as List).length, 2);
});
test('query single and error', () async {
final ok = FuncSpecClient('http://x', ClientOptions(httpClient: stub(200, {'id': 1}).$1));
final r = await ok.query('api/u');
expect(r.metadata, isNull);
expect((r.data as Map)['id'], 1);
final bad = FuncSpecClient(
'http://x',
ClientOptions(
httpClient: stub(400, {
'success': false,
'error': {'code': 'hook_error', 'message': 'Hook execution failed', 'detail': 'authentication required'}
}).$1),
);
await expectLater(
bad.query('api/u'),
throwsA(isA<ResolveSpecException>()
.having((e) => e.error.code, 'code', 'hook_error')
.having((e) => e.error.detail, 'detail', 'authentication required')),
);
});
});
}
+40
View File
@@ -0,0 +1,40 @@
# resolvespec-go
Go client for ResolveSpec (JSON body) and FunctionSpec. Module: `github.com/bitechdev/ResolveSpec/clients/resolvespec-go`. Stdlib only.
## Clients
| Type | Constructor | Methods |
|---|---|---|
| `Client` | `NewClient(baseURL, opts...)` | `GetMetadata` `Read` `Create` `Update` `Delete` |
| `FuncSpecClient` | `NewFuncSpecClient(baseURL, opts...)` | `Query` `QueryList` `Do` |
Client options: `WithToken`, `WithHeader`, `WithHTTPClient`. Precedence: Content-Type < custom headers < bearer token.
## ResolveSpec
- All methods take `ctx`; `Read`/`Update`/`Delete` take `RecordID` (`nil`, int/string → URL, `[]string` → body).
- `Options` fields use pointers for optional ints/bools (`Int(n)`, `Bool(b)`).
- Result: `*Response{Success, Data (raw JSON), Metadata}`; `resp.Decode(&v)`.
## FunctionSpec
- Routes are server-defined: pass the `path`.
- `Params` → query string (slice → repeated keys, bool → `true`/`false`).
- `FuncSpecOptions` → `X-*` headers: `Filters`, `SearchFilters`, `CustomSQLWhere`, `CustomSQLOr`, `Sort`, `Limit`, `Offset`, `Distinct`, `SkipCount`, `SkipCache`, `ResponseFormat`.
- `QueryList` fills `Metadata` from `Content-Range` (`items a-b/total`); 206 is success.
## Server quirks
- `Sort` is raw SQL in ORDER BY (client sends `col ASC|DESC`).
- One search operator per column.
- Values starting `ZIP_` / `__` are base64-decoded by the server.
- Non-ASCII, control chars and edge spaces are auto-encoded (`ZIP_`).
## Errors
`*Error{StatusCode, APIError{Code, Message, Detail, SQL}}`.
## Test
`go test ./...`
+121
View File
@@ -0,0 +1,121 @@
package resolvespec
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// APIError is the server error object.
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
Detail string `json:"detail,omitempty"` // server-side reason (funcspec / restheadspec)
SQL string `json:"sql,omitempty"`
}
// Error is returned on a non-2xx response or an unsuccessful result.
type Error struct {
StatusCode int
APIError
}
func (e *Error) Error() string {
if e.Message != "" {
return e.Message
}
return fmt.Sprintf("http %d", e.StatusCode)
}
type config struct {
baseURL string
token string
headers http.Header
http *http.Client
}
// Option configures a client.
type Option func(*config)
func WithToken(token string) Option { return func(c *config) { c.token = token } }
func WithHTTPClient(h *http.Client) Option { return func(c *config) { c.http = h } }
func WithHeader(name, value string) Option {
return func(c *config) { c.headers.Set(name, value) }
}
func newConfig(baseURL string, opts []Option) config {
c := config{baseURL: strings.TrimRight(baseURL, "/"), headers: http.Header{}, http: &http.Client{Timeout: 30 * time.Second}}
for _, o := range opts {
o(&c)
}
return c
}
// headers: Content-Type < custom headers < bearer token.
func (c *config) newRequest(ctx context.Context, method, u string, body []byte) (*http.Request, error) {
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
for k, vs := range c.headers {
req.Header[k] = append([]string(nil), vs...)
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
return req, nil
}
func (c *config) do(req *http.Request) (*http.Response, []byte, error) {
resp, err := c.http.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return resp, b, err
}
func errorFrom(status int, body []byte) *Error {
e := &Error{StatusCode: status}
var env struct {
Error *APIError `json:"error"`
}
if json.Unmarshal(body, &env) == nil && env.Error != nil {
e.APIError = *env.Error
}
if e.Message == "" {
text := ""
if !json.Valid(body) {
text = strings.TrimSpace(string(body))
if len(text) > 200 {
text = text[:200]
}
}
if text == "" {
text = fmt.Sprintf("%s (%d)", http.StatusText(status), status)
}
e.Message = text
}
return e
}
func buildURL(base, schema, entity string, id string) string {
u := base + "/" + url.PathEscape(schema) + "/" + url.PathEscape(entity)
if id != "" {
u += "/" + url.PathEscape(id)
}
return u
}
+274
View File
@@ -0,0 +1,274 @@
package resolvespec
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"unicode"
)
// FuncSpecOptions are 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.
type FuncSpecOptions struct {
Filters []FilterOption // eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr
SearchFilters map[string]string // X-SearchFilter-{col}: text ILIKE
CustomSQLWhere string // X-Custom-SQL-W
CustomSQLOr string // X-Custom-SQL-Or
Sort []SortOption
Limit *int
Offset *int
Distinct *bool
SkipCount *bool
SkipCache *bool
ResponseFormat string // simple | detail | syncfusion
}
// Params are query-string values. Slice values are sent as repeated keys (server: IN filter).
type Params map[string]any
// FuncSpecClient calls user-defined SQL endpoints. Routes are defined by the server app.
type FuncSpecClient struct{ cfg config }
func NewFuncSpecClient(baseURL string, opts ...Option) *FuncSpecClient {
return &FuncSpecClient{cfg: newConfig(baseURL, opts)}
}
var operatorMap = map[string]string{
"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",
}
func scalar(v any) string {
switch x := v.(type) {
case nil:
return ""
case bool:
return strconv.FormatBool(x)
case string:
return x
case fmt.Stringer:
return x.String()
}
return fmt.Sprint(v)
}
func filterValue(v any) string {
switch x := v.(type) {
case nil:
return ""
case []string:
return strings.Join(x, ",")
case []int:
parts := make([]string, len(x))
for i, n := range x {
parts[i] = strconv.Itoa(n)
}
return strings.Join(parts, ",")
case []any:
parts := make([]string, len(x))
for i, n := range x {
parts[i] = scalar(n)
}
return strings.Join(parts, ",")
}
return scalar(v)
}
// EncodeHeaderValue base64-encodes (UTF-8) with the ZIP_ prefix.
func EncodeHeaderValue(v string) string { return "ZIP_" + base64.StdEncoding.EncodeToString([]byte(v)) }
// DecodeHeaderValue decodes a value that may carry a ZIP_ or __ prefix (nested allowed).
func DecodeHeaderValue(v string) string {
for _, p := range []string{"ZIP_", "__"} {
if strings.HasPrefix(v, p) {
b64 := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(v[len(p):])
for len(b64)%4 != 0 {
b64 += "="
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return v
}
return DecodeHeaderValue(string(raw))
}
}
return v
}
// safe encodes values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).
func safe(v string) string {
if v != strings.TrimSpace(v) {
return EncodeHeaderValue(v)
}
for _, r := range v {
if r > unicode.MaxASCII || !unicode.IsPrint(r) {
return EncodeHeaderValue(v)
}
}
return v
}
// BuildHeaders builds the X-* headers understood by funcspec.ParseParameters.
func BuildHeaders(o *FuncSpecOptions) map[string]string {
h := map[string]string{}
if o == nil {
return h
}
for _, f := range o.Filters {
logic := f.LogicOperator
if logic == "" {
logic = "AND"
}
v := safe(filterValue(f.Value))
if f.Operator == "eq" && logic == "AND" {
h["X-FieldFilter-"+f.Column] = v
continue
}
op := operatorMap[f.Operator]
if op == "" {
op = f.Operator
}
kind := "X-SearchOp"
if logic == "OR" {
kind = "X-SearchOr"
}
h[kind+"-"+op+"-"+f.Column] = v
}
for col, text := range o.SearchFilters {
h["X-SearchFilter-"+col] = safe(text)
}
if o.CustomSQLWhere != "" {
h["X-Custom-SQL-W"] = safe(o.CustomSQLWhere)
}
if o.CustomSQLOr != "" {
h["X-Custom-SQL-Or"] = safe(o.CustomSQLOr)
}
if len(o.Sort) > 0 {
terms := make([]string, len(o.Sort))
for i, s := range o.Sort {
dir := "ASC"
if strings.EqualFold(s.Direction, "desc") {
dir = "DESC"
}
terms[i] = s.Column + " " + dir // funcspec puts this verbatim into ORDER BY
}
h["X-Sort"] = safe(strings.Join(terms, ","))
}
if o.Limit != nil {
h["X-Limit"] = strconv.Itoa(*o.Limit)
}
if o.Offset != nil {
h["X-Offset"] = strconv.Itoa(*o.Offset)
}
for name, v := range map[string]*bool{"X-Distinct": o.Distinct, "X-SkipCount": o.SkipCount, "X-SkipCache": o.SkipCache} {
if v != nil {
h[name] = strconv.FormatBool(*v)
}
}
switch o.ResponseFormat {
case "simple":
h["X-SimpleApi"] = "true"
case "detail":
h["X-DetailApi"] = "true"
case "syncfusion":
h["X-Syncfusion"] = "true"
}
return h
}
// BuildQuery builds query-string values: bools -> true/false, slices -> repeated keys, nil skipped.
func BuildQuery(p Params) url.Values {
q := url.Values{}
for k, v := range p {
switch x := v.(type) {
case nil:
case []string:
for _, e := range x {
q.Add(k, safe(e))
}
case []int:
for _, e := range x {
q.Add(k, strconv.Itoa(e))
}
case []any:
for _, e := range x {
q.Add(k, safe(scalar(e)))
}
default:
q.Add(k, safe(scalar(v)))
}
}
return q
}
var contentRange = regexp.MustCompile(`(\d+)-(\d+)/(\d+)`)
func metadata(h http.Header, o *FuncSpecOptions) *Metadata {
m := &Metadata{}
if g := contentRange.FindStringSubmatch(h.Get("Content-Range")); g != nil {
start, _ := strconv.ParseInt(g[1], 10, 64)
end, _ := strconv.ParseInt(g[2], 10, 64)
total, _ := strconv.ParseInt(g[3], 10, 64)
m.Total, m.Count, m.Filtered, m.Offset = total, end-start, total, int(start)
}
if o != nil && o.Limit != nil {
m.Limit = *o.Limit
}
return m
}
func (c *FuncSpecClient) call(ctx context.Context, method, path string, p Params, o *FuncSpecOptions, withMeta bool) (*Response, error) {
u := c.cfg.baseURL + "/" + strings.TrimLeft(path, "/")
if q := BuildQuery(p); len(q) > 0 {
u += "?" + q.Encode()
}
req, err := c.cfg.newRequest(ctx, strings.ToUpper(method), u, nil)
if err != nil {
return nil, err
}
for k, v := range BuildHeaders(o) {
req.Header.Set(k, v)
}
resp, b, err := c.cfg.do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 { // 206 is success
return nil, errorFrom(resp.StatusCode, b)
}
out := &Response{Success: true, Data: json.RawMessage(b)}
if len(b) == 0 {
out.Data = json.RawMessage("null")
}
if withMeta {
out.Metadata = metadata(resp.Header, o)
}
return out, nil
}
// Query calls a single-record endpoint (SqlQuery). Data is the row object.
func (c *FuncSpecClient) Query(ctx context.Context, path string, p Params, o *FuncSpecOptions) (*Response, error) {
return c.call(ctx, http.MethodGet, path, p, o, false)
}
// QueryList calls a list endpoint (SqlQueryList). Metadata comes from Content-Range.
func (c *FuncSpecClient) QueryList(ctx context.Context, path string, p Params, o *FuncSpecOptions) (*Response, error) {
return c.call(ctx, http.MethodGet, path, p, o, true)
}
// Do is like Query/QueryList with an explicit HTTP method (routes are app-defined).
func (c *FuncSpecClient) Do(ctx context.Context, method, path string, p Params, o *FuncSpecOptions, list bool) (*Response, error) {
return c.call(ctx, method, path, p, o, list)
}
+98
View File
@@ -0,0 +1,98 @@
package resolvespec
import (
"context"
"reflect"
"testing"
)
func TestBuildHeadersFilters(t *testing.T) {
got := BuildHeaders(&FuncSpecOptions{Filters: []FilterOption{
{Column: "status", Operator: "eq", Value: "active"},
{Column: "age", Operator: "gte", Value: 18},
{Column: "name", Operator: "contains", Value: "x", LogicOperator: "OR"},
{Column: "deleted", Operator: "is_null"},
{Column: "id", Operator: "in", Value: []int{1, 2}},
{Column: "p", Operator: "between_inclusive", Value: []any{1, 5}},
}})
want := map[string]string{
"X-FieldFilter-status": "active",
"X-SearchOp-greaterthanorequal-age": "18",
"X-SearchOr-contains-name": "x",
"X-SearchOp-empty-deleted": "",
"X-SearchOp-in-id": "1,2",
"X-SearchOp-betweeninclusive-p": "1,5",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%v", got)
}
}
func TestBuildHeadersMisc(t *testing.T) {
got := BuildHeaders(&FuncSpecOptions{
SearchFilters: map[string]string{"name": "bob"}, CustomSQLWhere: "a = 1", CustomSQLOr: "b = 2",
Sort: []SortOption{{"name", "asc"}, {"created_at", "DESC"}},
Limit: Int(5), Offset: Int(10), Distinct: Bool(true), SkipCount: Bool(true), SkipCache: Bool(false),
ResponseFormat: "syncfusion",
})
want := map[string]string{
"X-SearchFilter-name": "bob", "X-Custom-SQL-W": "a = 1", "X-Custom-SQL-Or": "b = 2",
"X-Sort": "name ASC,created_at DESC", "X-Limit": "5", "X-Offset": "10", "X-Distinct": "true",
"X-SkipCount": "true", "X-SkipCache": "false", "X-Syncfusion": "true",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%v", got)
}
}
func TestEncodeUnsafe(t *testing.T) {
h := BuildHeaders(&FuncSpecOptions{Filters: []FilterOption{{Column: "n", Operator: "eq", Value: "héllo"}, {Column: "m", Operator: "eq", Value: " pad"}}})
for _, k := range []string{"X-FieldFilter-n", "X-FieldFilter-m"} {
if len(h[k]) < 4 || h[k][:4] != "ZIP_" {
t.Fatalf("%s=%q", k, h[k])
}
}
if DecodeHeaderValue(h["X-FieldFilter-n"]) != "héllo" || DecodeHeaderValue(h["X-FieldFilter-m"]) != " pad" {
t.Fatal("roundtrip")
}
}
func TestBuildQuery(t *testing.T) {
q := BuildQuery(Params{"a": true, "b": []string{"x", "y"}, "c": nil, "d": 3})
if q.Get("a") != "true" || !reflect.DeepEqual(q["b"], []string{"x", "y"}) || q.Has("c") || q.Get("d") != "3" {
t.Fatalf("%v", q)
}
}
func TestQueryListMetadata(t *testing.T) {
srv, s := server(t, 206, `[{"id":1},{"id":2}]`, map[string]string{"Content-Range": "items 10-12/50"})
c := NewFuncSpecClient(srv.URL, WithToken("tok"))
resp, err := c.QueryList(context.Background(), "/api/users", Params{"org": 1}, &FuncSpecOptions{Limit: Int(2)})
if err != nil {
t.Fatal(err)
}
if s.method != "GET" || s.path != "/api/users?org=1" || s.header.Get("X-Limit") != "2" {
t.Fatalf("%s %v", s.path, s.header)
}
m := resp.Metadata
if m.Total != 50 || m.Count != 2 || m.Offset != 10 || m.Limit != 2 || m.Filtered != 50 {
t.Fatalf("%+v", m)
}
var rows []map[string]any
if err := resp.Decode(&rows); err != nil || len(rows) != 2 {
t.Fatal(err)
}
}
func TestQuerySingleNoMetadataAndError(t *testing.T) {
srv, _ := server(t, 200, `{"id":1}`, nil)
resp, err := NewFuncSpecClient(srv.URL).Query(context.Background(), "api/u", nil, nil)
if err != nil || resp.Metadata != nil {
t.Fatalf("%v %v", resp, err)
}
srv2, _ := server(t, 400, `{"success":false,"error":{"code":"hook_error","message":"Hook execution failed","detail":"authentication required"}}`, nil)
_, err = NewFuncSpecClient(srv2.URL).Query(context.Background(), "api/u", nil, nil)
if e := err.(*Error); e.Code != "hook_error" || e.Detail != "authentication required" {
t.Fatalf("%#v", e)
}
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/bitechdev/ResolveSpec/clients/resolvespec-go
go 1.22
+94
View File
@@ -0,0 +1,94 @@
package resolvespec
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// Client speaks the ResolveSpec JSON body protocol: POST {operation, data, options}.
type Client struct{ cfg config }
func NewClient(baseURL string, opts ...Option) *Client {
return &Client{cfg: newConfig(baseURL, opts)}
}
// RecordID is a single id (int or string, sent in the URL) or a []string (sent in the body).
type RecordID any
func urlID(id RecordID) string {
switch v := id.(type) {
case nil:
return ""
case []string:
return ""
case string:
return v
default:
return fmt.Sprint(v)
}
}
func bodyID(id RecordID) []string {
ids, _ := id.([]string)
return ids
}
type request struct {
Operation string `json:"operation"`
ID []string `json:"id,omitempty"`
Data any `json:"data,omitempty"`
Options *Options `json:"options,omitempty"`
}
func (c *Client) send(ctx context.Context, method, schema, entity, id string, body any) (*Response, error) {
var payload []byte
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
return nil, err
}
}
req, err := c.cfg.newRequest(ctx, method, buildURL(c.cfg.baseURL, schema, entity, id), payload)
if err != nil {
return nil, err
}
resp, b, err := c.cfg.do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, errorFrom(resp.StatusCode, b)
}
var out Response
if err := json.Unmarshal(b, &out); err != nil {
return nil, err
}
if !out.Success && out.Error != nil {
return nil, &Error{StatusCode: resp.StatusCode, APIError: *out.Error}
}
return &out, nil
}
// GetMetadata returns table metadata (GET /{schema}/{entity}).
func (c *Client) GetMetadata(ctx context.Context, schema, entity string) (*Response, error) {
return c.send(ctx, http.MethodGet, schema, entity, "", nil)
}
// Read reads records; id may be nil, an int/string (URL) or []string (body).
func (c *Client) Read(ctx context.Context, schema, entity string, id RecordID, opts *Options) (*Response, error) {
return c.send(ctx, http.MethodPost, schema, entity, urlID(id), request{Operation: "read", ID: bodyID(id), Options: opts})
}
func (c *Client) Create(ctx context.Context, schema, entity string, data any, opts *Options) (*Response, error) {
return c.send(ctx, http.MethodPost, schema, entity, "", request{Operation: "create", Data: data, Options: opts})
}
func (c *Client) Update(ctx context.Context, schema, entity string, data any, id RecordID, opts *Options) (*Response, error) {
return c.send(ctx, http.MethodPost, schema, entity, urlID(id), request{Operation: "update", ID: bodyID(id), Data: data, Options: opts})
}
func (c *Client) Delete(ctx context.Context, schema, entity string, id RecordID) (*Response, error) {
return c.send(ctx, http.MethodPost, schema, entity, urlID(id), request{Operation: "delete"})
}
@@ -0,0 +1,99 @@
package resolvespec
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
type seen struct {
method, path string
header http.Header
body map[string]any
}
func server(t *testing.T, status int, body string, hdr map[string]string) (*httptest.Server, *seen) {
t.Helper()
s := &seen{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.method, s.path, s.header = r.Method, r.URL.EscapedPath()+"?"+r.URL.RawQuery, r.Header
b, _ := io.ReadAll(r.Body)
if len(b) > 0 {
_ = json.Unmarshal(b, &s.body)
}
for k, v := range hdr {
w.Header().Set(k, v)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv, s
}
func TestReadBody(t *testing.T) {
srv, s := server(t, 200, `{"success":true,"data":[{"id":1}]}`, nil)
c := NewClient(srv.URL+"/", WithToken("tok"), WithHeader("X-Tenant", "a"))
resp, err := c.Read(context.Background(), "public", "users", nil, &Options{Limit: Int(5), Filters: []FilterOption{{Column: "a", Operator: "eq", Value: 1}}})
if err != nil {
t.Fatal(err)
}
if s.method != "POST" || s.path != "/public/users?" {
t.Fatalf("got %s %s", s.method, s.path)
}
if s.header.Get("Authorization") != "Bearer tok" || s.header.Get("X-Tenant") != "a" {
t.Fatalf("headers %v", s.header)
}
if s.body["operation"] != "read" || s.body["options"].(map[string]any)["limit"] != float64(5) {
t.Fatalf("body %v", s.body)
}
var rows []map[string]any
if err := resp.Decode(&rows); err != nil || len(rows) != 1 {
t.Fatalf("decode %v %v", rows, err)
}
}
func TestIDPlacement(t *testing.T) {
srv, s := server(t, 200, `{"success":true,"data":{}}`, nil)
c := NewClient(srv.URL)
ctx := context.Background()
_, _ = c.Read(ctx, "s", "e", 7, nil)
if s.path != "/s/e/7?" || s.body["id"] != nil {
t.Fatalf("%s %v", s.path, s.body)
}
_, _ = c.Update(ctx, "s", "e", map[string]any{"a": 1}, []string{"1", "2"}, nil)
if s.path != "/s/e?" || !reflect.DeepEqual(s.body["id"], []any{"1", "2"}) || s.body["operation"] != "update" {
t.Fatalf("%s %v", s.path, s.body)
}
_, _ = c.Delete(ctx, "s", "e", "a/b")
if s.path != "/s/e/a%2Fb?" || s.body["operation"] != "delete" {
t.Fatalf("%s %v", s.path, s.body)
}
_, _ = c.GetMetadata(ctx, "s", "e")
if s.method != "GET" {
t.Fatal(s.method)
}
}
func TestErrors(t *testing.T) {
srv, _ := server(t, 400, `{"success":false,"error":{"code":"x","message":"bad","detail":"why"}}`, nil)
_, err := NewClient(srv.URL).Read(context.Background(), "s", "e", nil, nil)
e, ok := err.(*Error)
if !ok || e.StatusCode != 400 || e.Code != "x" || e.Message != "bad" || e.Detail != "why" {
t.Fatalf("%#v", err)
}
srv2, _ := server(t, 502, "bad gateway", nil)
_, err = NewClient(srv2.URL).Read(context.Background(), "s", "e", nil, nil)
if e := err.(*Error); e.StatusCode != 502 || e.Message != "bad gateway" {
t.Fatalf("%#v", e)
}
srv3, _ := server(t, 200, `{"success":false,"error":{"code":"c","message":"nope"}}`, nil)
_, err = NewClient(srv3.URL).Read(context.Background(), "s", "e", nil, nil)
if e := err.(*Error); e.Message != "nope" {
t.Fatalf("%#v", e)
}
}
+107
View File
@@ -0,0 +1,107 @@
// Package resolvespec is a client for ResolveSpec (JSON body) and FunctionSpec endpoints.
package resolvespec
import "encoding/json"
// FilterOption mirrors common.FilterOption. Operator: eq neq gt gte lt lte like ilike in
// contains startswith endswith between between_inclusive is_null is_not_null.
type FilterOption struct {
Column string `json:"column"`
Operator string `json:"operator"`
Value any `json:"value"`
LogicOperator string `json:"logic_operator,omitempty"` // AND | OR
}
type SortOption struct {
Column string `json:"column"`
Direction string `json:"direction"` // asc | desc
}
type Parameter struct {
Name string `json:"name"`
Value string `json:"value"`
Sequence int `json:"sequence,omitempty"`
}
type CustomOperator struct {
Name string `json:"name"`
SQL string `json:"sql"`
}
type ComputedColumn struct {
Name string `json:"name"`
Expression string `json:"expression"`
}
type PreloadOption struct {
Relation string `json:"relation,omitempty"`
TableName string `json:"table_name,omitempty"`
Columns []string `json:"columns,omitempty"`
OmitColumns []string `json:"omit_columns,omitempty"`
Sort []SortOption `json:"sort,omitempty"`
Filters []FilterOption `json:"filters,omitempty"`
Where string `json:"where,omitempty"`
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
Updateable *bool `json:"updateable,omitempty"`
ComputedQL map[string]string `json:"computed_ql,omitempty"`
Recursive bool `json:"recursive,omitempty"`
PrimaryKey string `json:"primary_key,omitempty"`
RelatedKey string `json:"related_key,omitempty"`
ForeignKey string `json:"foreign_key,omitempty"`
RecursiveChildKey string `json:"recursive_child_key,omitempty"`
SQLJoins []string `json:"sql_joins,omitempty"`
JoinAliases []string `json:"join_aliases,omitempty"`
}
type VectorSearchOption struct {
Column string `json:"column"`
Vector []float64 `json:"vector"`
Metric string `json:"metric,omitempty"` // l2 (default) | cosine | ip
As string `json:"as,omitempty"` // distance alias, default _distance
Direction string `json:"direction,omitempty"`
}
// Options is the ResolveSpec request options object.
type Options struct {
Preload []PreloadOption `json:"preload,omitempty"`
Columns []string `json:"columns,omitempty"`
OmitColumns []string `json:"omit_columns,omitempty"`
Filters []FilterOption `json:"filters,omitempty"`
Sort []SortOption `json:"sort,omitempty"`
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
CustomOperators []CustomOperator `json:"customOperators,omitempty"`
ComputedColumns []ComputedColumn `json:"computedColumns,omitempty"`
Parameters []Parameter `json:"parameters,omitempty"`
CursorForward string `json:"cursor_forward,omitempty"`
CursorBackward string `json:"cursor_backward,omitempty"`
FetchRowNumber string `json:"fetch_row_number,omitempty"`
VectorSearch *VectorSearchOption `json:"vector_search,omitempty"`
}
// Metadata of a list response.
type Metadata struct {
Total int64 `json:"total"`
Count int64 `json:"count"`
Filtered int64 `json:"filtered"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// Response is the ResolveSpec envelope. Data is left raw for the caller to decode.
type Response struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
Metadata *Metadata `json:"metadata,omitempty"`
Error *APIError `json:"error,omitempty"`
}
// Decode unmarshals Data into v.
func (r *Response) Decode(v any) error { return json.Unmarshal(r.Data, v) }
// Int returns a pointer to n, for optional Options fields.
func Int(n int) *int { return &n }
// Bool returns a pointer to b.
func Bool(b bool) *bool { return &b }
+2
View File
@@ -0,0 +1,2 @@
target/
Cargo.lock
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "resolvespec"
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
description = "Client for ResolveSpec (JSON body) and FunctionSpec endpoints"
license = "MIT"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
thiserror = "1"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
wiremock = "0.6"
+40
View File
@@ -0,0 +1,40 @@
# resolvespec (Rust)
Rust client for ResolveSpec (JSON body) and FunctionSpec. Async (`reqwest` + `tokio`). MSRV 1.80.
## Clients
| Type | Constructor | Methods |
|---|---|---|
| `ResolveSpecClient` | `new(base_url)` / `from_builder(ClientBuilder)` | `get_metadata` `read` `create` `update` `delete` |
| `FuncSpecClient` | `new(base_url)` / `from_builder(ClientBuilder)` | `query` `query_list` `request` |
`ClientBuilder::new(url).token().header().timeout().http_client()`. Precedence: Content-Type < custom headers < bearer token.
## ResolveSpec
- `RecordId`: `Int`/`Str` → URL, `Many(Vec<String>)` → body (`From` impls provided).
- `Options` (`Default` + struct update), optional fields are `Option`/empty `Vec`.
- Result: `Response{success, data: serde_json::Value, metadata}`; `resp.decode::<T>()`.
## FunctionSpec
- Routes are server-defined: pass the `path`.
- `Params = BTreeMap<String, Param>` → query string (`Param::List` → repeated keys).
- `FuncSpecOptions` → `X-*` headers: `filters`, `search_filters`, `custom_sql_where`, `custom_sql_or`, `sort`, `limit`, `offset`, `distinct`, `skip_count`, `skip_cache`, `response_format`.
- `query_list` fills `metadata` from `Content-Range`; 206 is success.
## Server quirks
- `sort` is raw SQL in ORDER BY (client sends `col ASC|DESC`).
- One search operator per column.
- Values starting `ZIP_` / `__` are base64-decoded by the server.
- Non-ASCII, control chars and edge spaces are auto-encoded (`ZIP_`).
## Errors
`Error::Api { status, message, error: ApiError{code, message, detail, sql} }`, `Error::Http`, `Error::Json`.
## Test
`cargo test`
+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>,
}
+164
View File
@@ -0,0 +1,164 @@
use resolvespec::*;
use serde_json::json;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn read_posts_body_with_headers() {
let srv = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/public/users"))
.and(header("authorization", "Bearer tok"))
.and(header("x-tenant", "a"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": true, "data": [{"id": 1}]})))
.expect(1)
.mount(&srv)
.await;
let c = ResolveSpecClient::from_builder(ClientBuilder::new(&format!("{}/", srv.uri())).token("tok").header("X-Tenant", "a")).unwrap();
let opts = Options { limit: Some(5), filters: vec![FilterOption::new("a", "eq", 1)], ..Default::default() };
let r = c.read("public", "users", None, Some(&opts)).await.unwrap();
let rows: Vec<serde_json::Value> = r.decode().unwrap();
assert_eq!(rows.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&srv.received_requests().await.unwrap()[0].body).unwrap();
assert_eq!(body["operation"], "read");
assert_eq!(body["options"]["limit"], 5);
assert!(body.get("id").is_none());
}
#[tokio::test]
async fn id_placement() {
let srv = MockServer::start().await;
Mock::given(method("POST")).respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": true, "data": {}}))).mount(&srv).await;
let c = ResolveSpecClient::new(&srv.uri()).unwrap();
c.read("s", "e", Some(7.into()), None).await.unwrap();
c.update("s", "e", json!({"a": 1}), Some(vec!["1".to_string(), "2".to_string()].into()), None).await.unwrap();
c.delete("s", "e", "a/b").await.unwrap();
let reqs = srv.received_requests().await.unwrap();
assert_eq!(reqs[0].url.path(), "/s/e/7");
assert_eq!(reqs[1].url.path(), "/s/e");
let b: serde_json::Value = serde_json::from_slice(&reqs[1].body).unwrap();
assert_eq!(b["id"], json!(["1", "2"]));
assert_eq!(b["operation"], "update");
assert_eq!(reqs[2].url.path(), "/s/e/a%2Fb");
}
#[tokio::test]
async fn errors() {
let srv = MockServer::start().await;
Mock::given(path("/s/a")).respond_with(ResponseTemplate::new(400).set_body_json(json!({"success": false, "error": {"code": "x", "message": "bad", "detail": "why"}}))).mount(&srv).await;
Mock::given(path("/s/b")).respond_with(ResponseTemplate::new(502).set_body_string("bad gateway")).mount(&srv).await;
Mock::given(path("/s/c")).respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": false, "error": {"code": "c", "message": "nope"}}))).mount(&srv).await;
let c = ResolveSpecClient::new(&srv.uri()).unwrap();
match c.read("s", "a", None, None).await.unwrap_err() {
Error::Api { status, message, error } => {
assert_eq!((status, message.as_str(), error.code.as_str(), error.detail.as_deref()), (400, "bad", "x", Some("why")))
}
e => panic!("{e:?}"),
}
match c.read("s", "b", None, None).await.unwrap_err() {
Error::Api { status, message, .. } => assert_eq!((status, message.as_str()), (502, "bad gateway")),
e => panic!("{e:?}"),
}
assert_eq!(c.read("s", "c", None, None).await.unwrap_err().to_string(), "nope");
}
#[test]
fn headers_filters() {
let o = FuncSpecOptions {
filters: vec![
FilterOption::new("status", "eq", "active"),
FilterOption::new("age", "gte", 18),
FilterOption::new("name", "contains", "x").or(),
FilterOption::new("deleted", "is_null", serde_json::Value::Null),
FilterOption::new("id", "in", json!([1, 2])),
FilterOption::new("p", "between_inclusive", json!([1, 5])),
],
..Default::default()
};
let h = build_headers(&o);
let want: std::collections::BTreeMap<String, String> = [
("X-FieldFilter-status", "active"),
("X-SearchOp-greaterthanorequal-age", "18"),
("X-SearchOr-contains-name", "x"),
("X-SearchOp-empty-deleted", ""),
("X-SearchOp-in-id", "1,2"),
("X-SearchOp-betweeninclusive-p", "1,5"),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
assert_eq!(h, want);
}
#[test]
fn headers_misc_and_encoding() {
let o = FuncSpecOptions {
search_filters: [("name".to_string(), "bob".to_string())].into(),
custom_sql_where: Some("a = 1".into()),
custom_sql_or: Some("b = 2".into()),
sort: vec![SortOption::new("name", "asc"), SortOption::new("created_at", "DESC")],
limit: Some(5),
offset: Some(10),
distinct: Some(true),
skip_count: Some(true),
skip_cache: Some(false),
response_format: Some("syncfusion".into()),
..Default::default()
};
let h = build_headers(&o);
assert_eq!(h["X-Sort"], "name ASC,created_at DESC");
assert_eq!(h["X-SearchFilter-name"], "bob");
assert_eq!(h["X-Custom-SQL-W"], "a = 1");
assert_eq!(h["X-Limit"], "5");
assert_eq!(h["X-SkipCache"], "false");
assert_eq!(h["X-Syncfusion"], "true");
let o = FuncSpecOptions { filters: vec![FilterOption::new("n", "eq", "héllo"), FilterOption::new("m", "eq", " pad")], ..Default::default() };
let h = build_headers(&o);
assert!(h["X-FieldFilter-n"].starts_with("ZIP_"));
assert_eq!(decode_header_value(&h["X-FieldFilter-n"]), "héllo");
assert_eq!(decode_header_value(&h["X-FieldFilter-m"]), " pad");
}
#[test]
fn query_building() {
let mut p = Params::new();
p.insert("a".into(), true.into());
p.insert("b".into(), vec!["x".to_string(), "y".to_string()].into());
p.insert("d".into(), 3i64.into());
assert_eq!(build_query(&p), vec![("a".into(), "true".into()), ("b".into(), "x".into()), ("b".into(), "y".into()), ("d".into(), "3".into())]);
}
#[tokio::test]
async fn query_list_metadata() {
let srv = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/users"))
.and(query_param("org", "1"))
.and(header("x-limit", "2"))
.respond_with(ResponseTemplate::new(206).insert_header("Content-Range", "items 10-12/50").set_body_json(json!([{"id": 1}, {"id": 2}])))
.expect(1)
.mount(&srv)
.await;
let c = FuncSpecClient::from_builder(ClientBuilder::new(&srv.uri()).token("tok")).unwrap();
let mut p = Params::new();
p.insert("org".into(), 1i64.into());
let r = c.query_list("/api/users", &p, Some(&FuncSpecOptions { limit: Some(2), ..Default::default() })).await.unwrap();
assert_eq!(r.metadata.unwrap(), Metadata { total: 50, count: 2, filtered: 50, limit: 2, offset: 10 });
assert_eq!(r.data.as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn query_single_and_error() {
let srv = MockServer::start().await;
Mock::given(path("/api/ok")).respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": 1}))).mount(&srv).await;
Mock::given(path("/api/bad")).respond_with(ResponseTemplate::new(400).set_body_json(json!({"success": false, "error": {"code": "hook_error", "message": "Hook execution failed", "detail": "authentication required"}}))).mount(&srv).await;
let c = FuncSpecClient::new(&srv.uri()).unwrap();
let r = c.query("api/ok", &Params::new(), None).await.unwrap();
assert!(r.metadata.is_none());
assert_eq!(r.data["id"], 1);
match c.query("api/bad", &Params::new(), None).await.unwrap_err() {
Error::Api { error, .. } => assert_eq!((error.code.as_str(), error.detail.as_deref()), ("hook_error", Some("authentication required"))),
e => panic!("{e:?}"),
}
}