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