diff --git a/clients/README.md b/clients/README.md new file mode 100644 index 0000000..1039b0d --- /dev/null +++ b/clients/README.md @@ -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. diff --git a/clients/resolvespec-cs/.gitignore b/clients/resolvespec-cs/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/clients/resolvespec-cs/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/clients/resolvespec-cs/README.md b/clients/resolvespec-cs/README.md new file mode 100644 index 0000000..41488e5 --- /dev/null +++ b/clients/resolvespec-cs/README.md @@ -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` → body. +- `Options` with nullable properties; wire names via `JsonPropertyName`. +- Result: `Response{Success, Data (JsonElement), Metadata}`; `resp.Decode()`. + +## FunctionSpec + +- Routes are server-defined: pass the `path`. +- Params (`IDictionary`) → 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/` diff --git a/clients/resolvespec-cs/src/FuncSpecClient.cs b/clients/resolvespec-cs/src/FuncSpecClient.cs new file mode 100644 index 0000000..acdc588 --- /dev/null +++ b/clients/resolvespec-cs/src/FuncSpecClient.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ResolveSpec; + +/// +/// 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. +/// +public sealed class FuncSpecOptions +{ + /// eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr. + public List? Filters { get; set; } + /// X-SearchFilter-{col}: text ILIKE. + public Dictionary? SearchFilters { get; set; } + public string? CustomSqlWhere { get; set; } + public string? CustomSqlOr { get; set; } + public List? 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; } + /// simple | detail | syncfusion + public string? ResponseFormat { get; set; } +} + +/// Client for user-defined SQL endpoints. Routes are defined by the server application. +public sealed class FuncSpecClient +{ + readonly Transport _t; + + public FuncSpecClient(string baseUrl, ClientOptions? options = null) => _t = new Transport(baseUrl, options); + + static readonly Dictionary 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().Select(Scalar)) + : Scalar(v); + + /// Base64 (UTF-8) with the ZIP_ prefix. + public static string EncodeHeaderValue(string v) => "ZIP_" + Convert.ToBase64String(Encoding.UTF8.GetBytes(v)); + + /// Decode a value that may carry a ZIP_ or __ prefix (nested allowed). + 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; + } + + /// Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces). + static string Safe(string v) => + v != v.Trim() || v.Any(c => c > 127 || char.IsControl(c)) ? EncodeHeaderValue(v) : v; + + /// Build the X-* headers understood by funcspec.ParseParameters. + public static Dictionary BuildHeaders(FuncSpecOptions? o) + { + var h = new Dictionary(); + 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"; + + /// Build query-string pairs: bools -> true/false, lists -> repeated keys, null skipped. + public static List> BuildQuery(IDictionary? p) + { + var o = new List>(); + foreach (var (k, v) in p ?? new Dictionary()) + { + 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 CallAsync(HttpMethod method, string path, IDictionary? 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; + } + + /// Single-record endpoint (SqlQuery). Data is the row object. + public Task QueryAsync(string path, IDictionary? p = null, FuncSpecOptions? o = null, HttpMethod? method = null, CancellationToken ct = default) => + CallAsync(method ?? HttpMethod.Get, path, p, o, false, ct); + + /// List endpoint (SqlQueryList). Metadata comes from Content-Range. + public Task QueryListAsync(string path, IDictionary? p = null, FuncSpecOptions? o = null, HttpMethod? method = null, CancellationToken ct = default) => + CallAsync(method ?? HttpMethod.Get, path, p, o, true, ct); +} diff --git a/clients/resolvespec-cs/src/Http.cs b/clients/resolvespec-cs/src/Http.cs new file mode 100644 index 0000000..21e4b90 --- /dev/null +++ b/clients/resolvespec-cs/src/Http.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ResolveSpec; + +/// Shared HTTP configuration for both clients. +public sealed class ClientOptions +{ + public string? Token { get; set; } + public Dictionary Headers { get; } = new(StringComparer.OrdinalIgnoreCase); + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + /// Supply your own HttpClient (tests, pooling). Its BaseAddress is ignored. + 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 }; + } + + /// Content-Type < custom headers < per-call headers < bearer token. + public async Task<(HttpResponseMessage resp, string body)> SendAsync( + HttpMethod method, string url, string? json, IDictionary? 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(); + } + 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); +} diff --git a/clients/resolvespec-cs/src/ResolveSpec.csproj b/clients/resolvespec-cs/src/ResolveSpec.csproj new file mode 100644 index 0000000..bcbcd46 --- /dev/null +++ b/clients/resolvespec-cs/src/ResolveSpec.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + ResolveSpec + ResolveSpec.Client + 0.1.0 + Client for ResolveSpec (JSON body) and FunctionSpec endpoints + + diff --git a/clients/resolvespec-cs/src/ResolveSpecClient.cs b/clients/resolvespec-cs/src/ResolveSpecClient.cs new file mode 100644 index 0000000..c57e891 --- /dev/null +++ b/clients/resolvespec-cs/src/ResolveSpecClient.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ResolveSpec; + +/// Client for the ResolveSpec JSON body protocol: POST {operation, data, options}. +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 goes in the body. + static string? UrlId(object? id) => id switch + { + null => null, + string s => s, + IEnumerable => null, + _ => Convert.ToString(id, System.Globalization.CultureInfo.InvariantCulture), + }; + + static string[]? BodyId(object? id) => id is IEnumerable 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 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(text, Json) ?? new Response(); + if (!r.Success && r.Error != null) throw new ResolveSpecException(r.Error.Message, status, r.Error); + return r; + } + + /// GET /{schema}/{entity} + public Task GetMetadataAsync(string schema, string entity, CancellationToken ct = default) => + SendAsync(HttpMethod.Get, Url(schema, entity, null), null, ct); + + public Task 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 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 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 DeleteAsync(string schema, string entity, object id, CancellationToken ct = default) => + SendAsync(HttpMethod.Post, Url(schema, entity, UrlId(id)), new Request { Operation = "delete" }, ct); +} diff --git a/clients/resolvespec-cs/src/Types.cs b/clients/resolvespec-cs/src/Types.cs new file mode 100644 index 0000000..5ab7fd1 --- /dev/null +++ b/clients/resolvespec-cs/src/Types.cs @@ -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; } = ""; + /// eq neq gt gte lt lte like ilike in contains startswith endswith between between_inclusive is_null is_not_null + [JsonPropertyName("operator")] public string Operator { get; set; } = "eq"; + [JsonPropertyName("value")] public object? Value { get; set; } + /// AND | OR + [JsonPropertyName("logic_operator")] public string? LogicOperator { get; set; } +} + +public sealed class SortOption +{ + [JsonPropertyName("column")] public string Column { get; set; } = ""; + /// asc | desc + [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? Columns { get; set; } + [JsonPropertyName("omit_columns")] public List? OmitColumns { get; set; } + [JsonPropertyName("sort")] public List? Sort { get; set; } + [JsonPropertyName("filters")] public List? 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? 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? SqlJoins { get; set; } + [JsonPropertyName("join_aliases")] public List? JoinAliases { get; set; } +} + +public sealed class VectorSearchOption +{ + [JsonPropertyName("column")] public string Column { get; set; } = ""; + [JsonPropertyName("vector")] public List Vector { get; set; } = new(); + /// l2 (default) | cosine | ip + [JsonPropertyName("metric")] public string? Metric { get; set; } + /// Distance column alias, default _distance. + [JsonPropertyName("as")] public string? As { get; set; } + [JsonPropertyName("direction")] public string? Direction { get; set; } +} + +/// ResolveSpec request options object. +public sealed class Options +{ + [JsonPropertyName("preload")] public List? Preload { get; set; } + [JsonPropertyName("columns")] public List? Columns { get; set; } + [JsonPropertyName("omit_columns")] public List? OmitColumns { get; set; } + [JsonPropertyName("filters")] public List? Filters { get; set; } + [JsonPropertyName("sort")] public List? Sort { get; set; } + [JsonPropertyName("limit")] public int? Limit { get; set; } + [JsonPropertyName("offset")] public int? Offset { get; set; } + [JsonPropertyName("customOperators")] public List? CustomOperators { get; set; } + [JsonPropertyName("computedColumns")] public List? ComputedColumns { get; set; } + [JsonPropertyName("parameters")] public List? 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; } + /// Server-side reason (funcspec / restheadspec). + [JsonPropertyName("detail")] public string? Detail { get; set; } + [JsonPropertyName("sql")] public string? Sql { get; set; } +} + +/// ResolveSpec envelope. is raw JSON; use . +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() => Data.ValueKind == JsonValueKind.Undefined ? default : Data.Deserialize(); +} + +/// Thrown on a non-2xx response or an unsuccessful API result. +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 }; + } +} diff --git a/clients/resolvespec-cs/tests/ClientTests.cs b/clients/resolvespec-cs/tests/ClientTests.cs new file mode 100644 index 0000000..4a88311 --- /dev/null +++ b/clients/resolvespec-cs/tests/ClientTests.cs @@ -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 _headers; + + public Stub(HttpStatusCode status, string json, Dictionary? headers = null) + { + _status = status; _json = json; _headers = headers ?? new(); + } + + protected override async Task 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>>()!); + } + + [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(() => 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(() => 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(() => 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 + { + ["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 { ["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 { ["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(() => bad.QueryAsync("api/u")); + Assert.Equal(("hook_error", "authentication required"), (e.Error.Code, e.Error.Detail)); + } +} diff --git a/clients/resolvespec-cs/tests/ResolveSpec.Tests.csproj b/clients/resolvespec-cs/tests/ResolveSpec.Tests.csproj new file mode 100644 index 0000000..c8a7373 --- /dev/null +++ b/clients/resolvespec-cs/tests/ResolveSpec.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + diff --git a/clients/resolvespec-dart/.gitignore b/clients/resolvespec-dart/.gitignore new file mode 100644 index 0000000..315dbaf --- /dev/null +++ b/clients/resolvespec-dart/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +pubspec.lock +build/ diff --git a/clients/resolvespec-dart/README.md b/clients/resolvespec-dart/README.md new file mode 100644 index 0000000..825f1af --- /dev/null +++ b/clients/resolvespec-dart/README.md @@ -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` → 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` diff --git a/clients/resolvespec-dart/analysis_options.yaml b/clients/resolvespec-dart/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/clients/resolvespec-dart/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/clients/resolvespec-dart/lib/resolvespec.dart b/clients/resolvespec-dart/lib/resolvespec.dart new file mode 100644 index 0000000..8381929 --- /dev/null +++ b/clients/resolvespec-dart/lib/resolvespec.dart @@ -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'; diff --git a/clients/resolvespec-dart/lib/src/client.dart b/clients/resolvespec-dart/lib/src/client.dart new file mode 100644 index 0000000..3c2baa8 --- /dev/null +++ b/clients/resolvespec-dart/lib/src/client.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 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 send(String method, Uri uri, {String? body, Map? extra}) { + final headers = {'Content-Type': 'application/json'}; + void merge(Map 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 && parsed['error'] is Map) { + err = ApiError.fromJson(parsed['error'] as Map); + } + } 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); + } +} diff --git a/clients/resolvespec-dart/lib/src/funcspec.dart b/clients/resolvespec-dart/lib/src/funcspec.dart new file mode 100644 index 0000000..49e9226 --- /dev/null +++ b/clients/resolvespec-dart/lib/src/funcspec.dart @@ -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? filters; + + /// X-SearchFilter-{col}: text ILIKE. + final Map? searchFilters; + final String? customSqlWhere; + final String? customSqlOr; + final List? 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 buildHeaders(FuncSpecOptions? o) { + final h = {}; + if (o == null) return h; + + for (final f in o.filters ?? const []) { + 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> buildQuery(Map? params) { + final out = >{}; + 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 _call(String method, String path, Map? params, FuncSpecOptions? o, bool list) async { + final base = Uri.parse('${_t.baseUrl}/${path.replaceAll(RegExp(r'^/+'), '')}'); + final pairs = []; + 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 query(String path, {Map? params, FuncSpecOptions? options, String method = 'GET'}) => + _call(method.toUpperCase(), path, params, options, false); + + /// List endpoint (SqlQueryList). Metadata comes from Content-Range. + Future queryList(String path, {Map? params, FuncSpecOptions? options, String method = 'GET'}) => + _call(method.toUpperCase(), path, params, options, true); +} diff --git a/clients/resolvespec-dart/lib/src/resolvespec.dart b/clients/resolvespec-dart/lib/src/resolvespec.dart new file mode 100644 index 0000000..1ec5cf8 --- /dev/null +++ b/clients/resolvespec-dart/lib/src/resolvespec.dart @@ -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` 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? _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 _send(String method, Uri url, Map? 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); + if (!r.success && r.error != null) throw ResolveSpecException(r.error!.message, resp.statusCode, r.error); + return r; + } + + /// GET /{schema}/{entity} + Future getMetadata(String schema, String entity) => _send('GET', _url(schema, entity, null), null); + + Future 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 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 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 delete(String schema, String entity, Object id) => + _send('POST', _url(schema, entity, _urlId(id)), {'operation': 'delete'}); +} diff --git a/clients/resolvespec-dart/lib/src/types.dart b/clients/resolvespec-dart/lib/src/types.dart new file mode 100644 index 0000000..f56d613 --- /dev/null +++ b/clients/resolvespec-dart/lib/src/types.dart @@ -0,0 +1,268 @@ +// Types aligned with Go pkg/common/types.go. toJson() emits the wire names. + +Map _compact(Map 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 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 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 toJson() => _compact({'name': name, 'value': value, 'sequence': sequence}); +} + +class CustomOperator { + final String name; + final String sql; + + const CustomOperator(this.name, this.sql); + + Map toJson() => {'name': name, 'sql': sql}; +} + +class ComputedColumn { + final String name; + final String expression; + + const ComputedColumn(this.name, this.expression); + + Map toJson() => {'name': name, 'expression': expression}; +} + +class PreloadOption { + final String? relation; + final String? tableName; + final List? columns; + final List? omitColumns; + final List? sort; + final List? filters; + final String? where; + final int? limit; + final int? offset; + final bool? updateable; + final Map? computedQl; + final bool? recursive; + final String? primaryKey; + final String? relatedKey; + final String? foreignKey; + final String? recursiveChildKey; + final List? sqlJoins; + final List? 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 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 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 toJson() => + _compact({'column': column, 'vector': vector, 'metric': metric, 'as': as, 'direction': direction}); +} + +/// ResolveSpec request options object. +class Options { + final List? preload; + final List? columns; + final List? omitColumns; + final List? filters; + final List? sort; + final int? limit; + final int? offset; + final List? customOperators; + final List? computedColumns; + final List? 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 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 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 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 j) => Response( + success: j['success'] == true, + data: j['data'], + metadata: j['metadata'] is Map ? Metadata.fromJson(j['metadata'] as Map) : null, + error: j['error'] is Map ? ApiError.fromJson(j['error'] as Map) : null, + ); +} diff --git a/clients/resolvespec-dart/pubspec.yaml b/clients/resolvespec-dart/pubspec.yaml new file mode 100644 index 0000000..41989ba --- /dev/null +++ b/clients/resolvespec-dart/pubspec.yaml @@ -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 diff --git a/clients/resolvespec-dart/test/client_test.dart b/clients/resolvespec-dart/test/client_test.dart new file mode 100644 index 0000000..7e213a1 --- /dev/null +++ b/clients/resolvespec-dart/test/client_test.dart @@ -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) stub(int status, Object body, {Map headers = const {}}) { + final seen = []; + 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; + 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; + 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() + .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().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().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() + .having((e) => e.error.code, 'code', 'hook_error') + .having((e) => e.error.detail, 'detail', 'authentication required')), + ); + }); + }); +} diff --git a/clients/resolvespec-go/README.md b/clients/resolvespec-go/README.md new file mode 100644 index 0000000..f05effe --- /dev/null +++ b/clients/resolvespec-go/README.md @@ -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 ./...` diff --git a/clients/resolvespec-go/client.go b/clients/resolvespec-go/client.go new file mode 100644 index 0000000..104c182 --- /dev/null +++ b/clients/resolvespec-go/client.go @@ -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 +} diff --git a/clients/resolvespec-go/funcspec.go b/clients/resolvespec-go/funcspec.go new file mode 100644 index 0000000..f4a63ca --- /dev/null +++ b/clients/resolvespec-go/funcspec.go @@ -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) +} diff --git a/clients/resolvespec-go/funcspec_test.go b/clients/resolvespec-go/funcspec_test.go new file mode 100644 index 0000000..5ffff1e --- /dev/null +++ b/clients/resolvespec-go/funcspec_test.go @@ -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) + } +} diff --git a/clients/resolvespec-go/go.mod b/clients/resolvespec-go/go.mod new file mode 100644 index 0000000..952f301 --- /dev/null +++ b/clients/resolvespec-go/go.mod @@ -0,0 +1,3 @@ +module github.com/bitechdev/ResolveSpec/clients/resolvespec-go + +go 1.22 diff --git a/clients/resolvespec-go/resolvespec.go b/clients/resolvespec-go/resolvespec.go new file mode 100644 index 0000000..2f57b3f --- /dev/null +++ b/clients/resolvespec-go/resolvespec.go @@ -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"}) +} diff --git a/clients/resolvespec-go/resolvespec_test.go b/clients/resolvespec-go/resolvespec_test.go new file mode 100644 index 0000000..91598bd --- /dev/null +++ b/clients/resolvespec-go/resolvespec_test.go @@ -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) + } +} diff --git a/clients/resolvespec-go/types.go b/clients/resolvespec-go/types.go new file mode 100644 index 0000000..e00c2a4 --- /dev/null +++ b/clients/resolvespec-go/types.go @@ -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 } diff --git a/clients/resolvespec-rs/.gitignore b/clients/resolvespec-rs/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/clients/resolvespec-rs/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/clients/resolvespec-rs/Cargo.toml b/clients/resolvespec-rs/Cargo.toml new file mode 100644 index 0000000..20aa5e3 --- /dev/null +++ b/clients/resolvespec-rs/Cargo.toml @@ -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" diff --git a/clients/resolvespec-rs/README.md b/clients/resolvespec-rs/README.md new file mode 100644 index 0000000..cf8a83c --- /dev/null +++ b/clients/resolvespec-rs/README.md @@ -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)` → body (`From` impls provided). +- `Options` (`Default` + struct update), optional fields are `Option`/empty `Vec`. +- Result: `Response{success, data: serde_json::Value, metadata}`; `resp.decode::()`. + +## FunctionSpec + +- Routes are server-defined: pass the `path`. +- `Params = BTreeMap` → 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` diff --git a/clients/resolvespec-rs/src/client.rs b/clients/resolvespec-rs/src/client.rs new file mode 100644 index 0000000..8d9c876 --- /dev/null +++ b/clients/resolvespec-rs/src/client.rs @@ -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, + pub headers: HashMap, + pub http: reqwest::Client, +} + +/// Builder options shared by both clients. +#[derive(Default, Clone)] +pub struct ClientBuilder { + base_url: String, + token: Option, + headers: HashMap, + timeout: Option, + http: Option, +} + +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 { + 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) -> 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 +} diff --git a/clients/resolvespec-rs/src/error.rs b/clients/resolvespec-rs/src/error.rs new file mode 100644 index 0000000..cbf00e2 --- /dev/null +++ b/clients/resolvespec-rs/src/error.rs @@ -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 = std::result::Result; + +pub(crate) fn error_from(status: u16, body: &str) -> Error { + let parsed: Option = 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::() } 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 } +} diff --git a/clients/resolvespec-rs/src/funcspec.rs b/clients/resolvespec-rs/src/funcspec.rs new file mode 100644 index 0000000..4d3c12d --- /dev/null +++ b/clients/resolvespec-rs/src/funcspec.rs @@ -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, + /// X-SearchFilter-{col}: text ILIKE. + pub search_filters: BTreeMap, + pub custom_sql_where: Option, + pub custom_sql_or: Option, + pub sort: Vec, + pub limit: Option, + pub offset: Option, + pub distinct: Option, + pub skip_count: Option, + pub skip_cache: Option, + /// simple | detail | syncfusion + pub response_format: Option, +} + +/// 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), +} + +impl From<&str> for Param { + fn from(v: &str) -> Self { + Self::Str(v.into()) + } +} +impl From for Param { + fn from(v: String) -> Self { + Self::Str(v) + } +} +impl From for Param { + fn from(v: i64) -> Self { + Self::Int(v) + } +} +impl From for Param { + fn from(v: bool) -> Self { + Self::Bool(v) + } +} +impl From> for Param { + fn from(v: Vec) -> Self { + Self::List(v) + } +} + +pub type Params = BTreeMap; + +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::>().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 { + 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 = 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) -> 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::()) { + if let (Ok(s), Ok(e)) = (s.parse::(), e.parse::()) { + 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::from_builder(ClientBuilder::new(base_url)) + } + + pub fn from_builder(b: ClientBuilder) -> Result { + Ok(Self { cfg: b.config()? }) + } + + async fn call(&self, method: Method, path: &str, params: &Params, options: Option<&FuncSpecOptions>, list: bool) -> Result { + let url = format!("{}/{}", self.cfg.base_url, path.trim_start_matches('/')); + let extra: HashMap = 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 { + 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 { + 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 { + self.call(method, path, params, options, list).await + } +} diff --git a/clients/resolvespec-rs/src/lib.rs b/clients/resolvespec-rs/src/lib.rs new file mode 100644 index 0000000..3ff20c5 --- /dev/null +++ b/clients/resolvespec-rs/src/lib.rs @@ -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::*; diff --git a/clients/resolvespec-rs/src/resolvespec.rs b/clients/resolvespec-rs/src/resolvespec.rs new file mode 100644 index 0000000..5757cf0 --- /dev/null +++ b/clients/resolvespec-rs/src/resolvespec.rs @@ -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), +} + +impl From 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> for RecordId { + fn from(v: Vec) -> Self { + Self::Many(v) + } +} + +fn url_id(id: &Option) -> Option { + 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>, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + #[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::from_builder(ClientBuilder::new(base_url)) + } + + pub fn from_builder(b: ClientBuilder) -> Result { + Ok(Self { cfg: b.config()? }) + } + + fn url(&self, schema: &str, entity: &str, id: Option) -> 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>) -> Result { + 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 { + self.send(Method::GET, self.url(schema, entity, None), None).await + } + + pub async fn read(&self, schema: &str, entity: &str, id: Option, options: Option<&Options>) -> Result { + 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 { + 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, options: Option<&Options>) -> Result { + 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) -> Result { + 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) -> Option> { + match id { + Some(RecordId::Many(v)) => Some(v.clone()), + _ => None, + } +} diff --git a/clients/resolvespec-rs/src/types.rs b/clients/resolvespec-rs/src/types.rs new file mode 100644 index 0000000..6f09e5a --- /dev/null +++ b/clients/resolvespec-rs/src/types.rs @@ -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, // AND | OR +} + +impl FilterOption { + pub fn new(column: &str, operator: &str, value: impl Into) -> 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, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub table_name: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub columns: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub omit_columns: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub sort: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub filters: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#where: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updateable: Option, + #[serde(skip_serializing_if = "HashMap::is_empty", default)] + pub computed_ql: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub recursive: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub related_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub foreign_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recursive_child_key: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub sql_joins: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub join_aliases: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VectorSearchOption { + pub column: String, + pub vector: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub metric: Option, // l2 (default) | cosine | ip + #[serde(rename = "as", skip_serializing_if = "Option::is_none")] + pub alias: Option, // distance alias, default _distance + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, +} + +/// ResolveSpec request options object. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Options { + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub preload: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub columns: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub omit_columns: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub filters: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub sort: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(rename = "customOperators", skip_serializing_if = "Vec::is_empty", default)] + pub custom_operators: Vec, + #[serde(rename = "computedColumns", skip_serializing_if = "Vec::is_empty", default)] + pub computed_columns: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub parameters: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor_forward: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor_backward: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fetch_row_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub vector_search: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Response { + /// Decode `data` into `T`. + pub fn decode(&self) -> Result { + 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, + /// Server-side reason (funcspec / restheadspec). + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sql: Option, +} diff --git a/clients/resolvespec-rs/tests/client.rs b/clients/resolvespec-rs/tests/client.rs new file mode 100644 index 0000000..a0f2522 --- /dev/null +++ b/clients/resolvespec-rs/tests/client.rs @@ -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 = 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 = [ + ("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:?}"), + } +}