mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
feat(clients): add Go, Rust, C# and Dart clients for ResolveSpec and FunctionSpec
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
obj/
|
||||
@@ -0,0 +1,43 @@
|
||||
# ResolveSpec.Client (C#)
|
||||
|
||||
.NET 8 client for ResolveSpec (JSON body) and FunctionSpec. `System.Text.Json`, no other dependencies.
|
||||
|
||||
> Not compiled or tested yet (no .NET SDK was available). Run `dotnet test tests/` first.
|
||||
|
||||
## Clients
|
||||
|
||||
| Type | Constructor | Methods |
|
||||
|---|---|---|
|
||||
| `ResolveSpecClient` | `(baseUrl, ClientOptions?)` | `GetMetadataAsync` `ReadAsync` `CreateAsync` `UpdateAsync` `DeleteAsync` |
|
||||
| `FuncSpecClient` | `(baseUrl, ClientOptions?)` | `QueryAsync` `QueryListAsync` |
|
||||
|
||||
`ClientOptions`: `Token`, `Headers`, `Timeout`, `HttpClient`. Precedence: Content-Type < custom headers < bearer token.
|
||||
|
||||
## ResolveSpec
|
||||
|
||||
- `id`: int/long/string → URL, `IEnumerable<string>` → body.
|
||||
- `Options` with nullable properties; wire names via `JsonPropertyName`.
|
||||
- Result: `Response{Success, Data (JsonElement), Metadata}`; `resp.Decode<T>()`.
|
||||
|
||||
## FunctionSpec
|
||||
|
||||
- Routes are server-defined: pass the `path`.
|
||||
- Params (`IDictionary<string, object?>`) → query string (enumerable → repeated keys, bool → `true`/`false`, null skipped).
|
||||
- `FuncSpecOptions` → `X-*` headers: `Filters`, `SearchFilters`, `CustomSqlWhere`, `CustomSqlOr`, `Sort`, `Limit`, `Offset`, `Distinct`, `SkipCount`, `SkipCache`, `ResponseFormat`.
|
||||
- `QueryListAsync` fills `Metadata` from `Content-Range`; 206 is success.
|
||||
- Static helpers: `BuildHeaders`, `BuildQuery`, `EncodeHeaderValue`, `DecodeHeaderValue`.
|
||||
|
||||
## Server quirks
|
||||
|
||||
- `Sort` is raw SQL in ORDER BY (client sends `col ASC|DESC`).
|
||||
- One search operator per column.
|
||||
- Values starting `ZIP_` / `__` are base64-decoded by the server.
|
||||
- Non-ASCII, control chars and edge spaces are auto-encoded (`ZIP_`).
|
||||
|
||||
## Errors
|
||||
|
||||
`ResolveSpecException{StatusCode, Message, Error{Code, Detail, Sql}}`.
|
||||
|
||||
## Test
|
||||
|
||||
`dotnet test tests/`
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ResolveSpec;
|
||||
|
||||
/// <summary>
|
||||
/// Options sent to funcspec endpoints as X-* headers.
|
||||
/// Server behaviour (pkg/funcspec): Sort is inserted raw into ORDER BY (so it is sent as SQL terms);
|
||||
/// only one search operator per column is kept; values starting with "ZIP_" or "__" are
|
||||
/// base64-decoded by the server, so such plaintext values cannot be sent faithfully.
|
||||
/// </summary>
|
||||
public sealed class FuncSpecOptions
|
||||
{
|
||||
/// <summary>eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr.</summary>
|
||||
public List<FilterOption>? Filters { get; set; }
|
||||
/// <summary>X-SearchFilter-{col}: text ILIKE.</summary>
|
||||
public Dictionary<string, string>? SearchFilters { get; set; }
|
||||
public string? CustomSqlWhere { get; set; }
|
||||
public string? CustomSqlOr { get; set; }
|
||||
public List<SortOption>? Sort { get; set; }
|
||||
public int? Limit { get; set; }
|
||||
public int? Offset { get; set; }
|
||||
public bool? Distinct { get; set; }
|
||||
public bool? SkipCount { get; set; }
|
||||
public bool? SkipCache { get; set; }
|
||||
/// <summary>simple | detail | syncfusion</summary>
|
||||
public string? ResponseFormat { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Client for user-defined SQL endpoints. Routes are defined by the server application.</summary>
|
||||
public sealed class FuncSpecClient
|
||||
{
|
||||
readonly Transport _t;
|
||||
|
||||
public FuncSpecClient(string baseUrl, ClientOptions? options = null) => _t = new Transport(baseUrl, options);
|
||||
|
||||
static readonly Dictionary<string, string> OperatorMap = new()
|
||||
{
|
||||
["eq"] = "equals", ["neq"] = "notequals", ["gt"] = "greaterthan", ["gte"] = "greaterthanorequal",
|
||||
["lt"] = "lessthan", ["lte"] = "lessthanorequal", ["like"] = "contains", ["ilike"] = "contains",
|
||||
["contains"] = "contains", ["startswith"] = "beginswith", ["endswith"] = "endswith", ["in"] = "in",
|
||||
["between"] = "between", ["between_inclusive"] = "betweeninclusive",
|
||||
["is_null"] = "empty", ["is_not_null"] = "notempty",
|
||||
};
|
||||
|
||||
static string Scalar(object? v) => v switch
|
||||
{
|
||||
null => "",
|
||||
string s => s,
|
||||
bool b => b ? "true" : "false",
|
||||
JsonElement { ValueKind: JsonValueKind.Null } => "",
|
||||
JsonElement e => e.ValueKind == JsonValueKind.String ? e.GetString() ?? "" : e.ToString(),
|
||||
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => v.ToString() ?? "",
|
||||
};
|
||||
|
||||
static string FilterValue(object? v) =>
|
||||
v is System.Collections.IEnumerable list and not string
|
||||
? string.Join(",", list.Cast<object?>().Select(Scalar))
|
||||
: Scalar(v);
|
||||
|
||||
/// <summary>Base64 (UTF-8) with the ZIP_ prefix.</summary>
|
||||
public static string EncodeHeaderValue(string v) => "ZIP_" + Convert.ToBase64String(Encoding.UTF8.GetBytes(v));
|
||||
|
||||
/// <summary>Decode a value that may carry a ZIP_ or __ prefix (nested allowed).</summary>
|
||||
public static string DecodeHeaderValue(string v)
|
||||
{
|
||||
foreach (var p in new[] { "ZIP_", "__" })
|
||||
{
|
||||
if (!v.StartsWith(p, StringComparison.Ordinal)) continue;
|
||||
var b64 = Regex.Replace(v[p.Length..], "[\n\r ]", "");
|
||||
b64 = b64.PadRight(b64.Length + (4 - b64.Length % 4) % 4, '=');
|
||||
try { return DecodeHeaderValue(Encoding.UTF8.GetString(Convert.FromBase64String(b64))); }
|
||||
catch (FormatException) { return v; }
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/// <summary>Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).</summary>
|
||||
static string Safe(string v) =>
|
||||
v != v.Trim() || v.Any(c => c > 127 || char.IsControl(c)) ? EncodeHeaderValue(v) : v;
|
||||
|
||||
/// <summary>Build the X-* headers understood by funcspec.ParseParameters.</summary>
|
||||
public static Dictionary<string, string> BuildHeaders(FuncSpecOptions? o)
|
||||
{
|
||||
var h = new Dictionary<string, string>();
|
||||
if (o == null) return h;
|
||||
|
||||
foreach (var f in o.Filters ?? new())
|
||||
{
|
||||
var logic = string.IsNullOrEmpty(f.LogicOperator) ? "AND" : f.LogicOperator;
|
||||
var v = Safe(FilterValue(f.Value));
|
||||
if (f.Operator == "eq" && logic == "AND") { h[$"X-FieldFilter-{f.Column}"] = v; continue; }
|
||||
var op = OperatorMap.TryGetValue(f.Operator, out var m) ? m : f.Operator;
|
||||
h[$"{(logic == "OR" ? "X-SearchOr" : "X-SearchOp")}-{op}-{f.Column}"] = v;
|
||||
}
|
||||
foreach (var (col, text) in o.SearchFilters ?? new()) h[$"X-SearchFilter-{col}"] = Safe(text);
|
||||
if (!string.IsNullOrEmpty(o.CustomSqlWhere)) h["X-Custom-SQL-W"] = Safe(o.CustomSqlWhere);
|
||||
if (!string.IsNullOrEmpty(o.CustomSqlOr)) h["X-Custom-SQL-Or"] = Safe(o.CustomSqlOr);
|
||||
if (o.Sort is { Count: > 0 })
|
||||
{
|
||||
// funcspec puts this verbatim into ORDER BY
|
||||
h["X-Sort"] = Safe(string.Join(",", o.Sort.Select(s =>
|
||||
$"{s.Column} {(string.Equals(s.Direction, "desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC")}")));
|
||||
}
|
||||
if (o.Limit != null) h["X-Limit"] = o.Limit.Value.ToString(CultureInfo.InvariantCulture);
|
||||
if (o.Offset != null) h["X-Offset"] = o.Offset.Value.ToString(CultureInfo.InvariantCulture);
|
||||
if (o.Distinct != null) h["X-Distinct"] = Bool(o.Distinct.Value);
|
||||
if (o.SkipCount != null) h["X-SkipCount"] = Bool(o.SkipCount.Value);
|
||||
if (o.SkipCache != null) h["X-SkipCache"] = Bool(o.SkipCache.Value);
|
||||
switch (o.ResponseFormat)
|
||||
{
|
||||
case "simple": h["X-SimpleApi"] = "true"; break;
|
||||
case "detail": h["X-DetailApi"] = "true"; break;
|
||||
case "syncfusion": h["X-Syncfusion"] = "true"; break;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
static string Bool(bool b) => b ? "true" : "false";
|
||||
|
||||
/// <summary>Build query-string pairs: bools -> true/false, lists -> repeated keys, null skipped.</summary>
|
||||
public static List<KeyValuePair<string, string>> BuildQuery(IDictionary<string, object?>? p)
|
||||
{
|
||||
var o = new List<KeyValuePair<string, string>>();
|
||||
foreach (var (k, v) in p ?? new Dictionary<string, object?>())
|
||||
{
|
||||
if (v == null) continue;
|
||||
if (v is System.Collections.IEnumerable list and not string)
|
||||
foreach (var e in list) o.Add(new(k, Safe(Scalar(e))));
|
||||
else o.Add(new(k, Safe(Scalar(v))));
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
static readonly Regex ContentRange = new(@"(\d+)-(\d+)/(\d+)");
|
||||
|
||||
static Metadata MetadataFrom(string? contentRange, FuncSpecOptions? o)
|
||||
{
|
||||
var m = new Metadata { Limit = o?.Limit ?? 0 };
|
||||
var g = ContentRange.Match(contentRange ?? "");
|
||||
if (g.Success)
|
||||
{
|
||||
var start = long.Parse(g.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
var end = long.Parse(g.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
var total = long.Parse(g.Groups[3].Value, CultureInfo.InvariantCulture);
|
||||
m.Total = total; m.Filtered = total; m.Count = end - start; m.Offset = start;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
async Task<Response> CallAsync(HttpMethod method, string path, IDictionary<string, object?>? p, FuncSpecOptions? o, bool list, CancellationToken ct)
|
||||
{
|
||||
var url = $"{_t.BaseUrl}/{path.TrimStart('/')}";
|
||||
var q = BuildQuery(p);
|
||||
if (q.Count > 0)
|
||||
url += "?" + string.Join("&", q.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||
|
||||
var (resp, text) = await _t.SendAsync(method, url, null, BuildHeaders(o), ct).ConfigureAwait(false);
|
||||
var status = (int)resp.StatusCode;
|
||||
if (!resp.IsSuccessStatusCode) throw Transport.ErrorFrom(status, text, resp.ReasonPhrase); // 206 is success
|
||||
|
||||
var r = new Response
|
||||
{
|
||||
Success = true,
|
||||
Data = string.IsNullOrWhiteSpace(text) ? JsonDocument.Parse("null").RootElement.Clone() : JsonDocument.Parse(text).RootElement.Clone(),
|
||||
};
|
||||
if (list)
|
||||
{
|
||||
resp.Headers.TryGetValues("Content-Range", out var cr);
|
||||
r.Metadata = MetadataFrom(cr?.FirstOrDefault(), o);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>Single-record endpoint (SqlQuery). Data is the row object.</summary>
|
||||
public Task<Response> QueryAsync(string path, IDictionary<string, object?>? p = null, FuncSpecOptions? o = null, HttpMethod? method = null, CancellationToken ct = default) =>
|
||||
CallAsync(method ?? HttpMethod.Get, path, p, o, false, ct);
|
||||
|
||||
/// <summary>List endpoint (SqlQueryList). Metadata comes from Content-Range.</summary>
|
||||
public Task<Response> QueryListAsync(string path, IDictionary<string, object?>? p = null, FuncSpecOptions? o = null, HttpMethod? method = null, CancellationToken ct = default) =>
|
||||
CallAsync(method ?? HttpMethod.Get, path, p, o, true, ct);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ResolveSpec;
|
||||
|
||||
/// <summary>Shared HTTP configuration for both clients.</summary>
|
||||
public sealed class ClientOptions
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
public Dictionary<string, string> Headers { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
/// <summary>Supply your own HttpClient (tests, pooling). Its BaseAddress is ignored.</summary>
|
||||
public HttpClient? HttpClient { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class Transport
|
||||
{
|
||||
public readonly string BaseUrl;
|
||||
readonly ClientOptions _o;
|
||||
readonly HttpClient _http;
|
||||
|
||||
public Transport(string baseUrl, ClientOptions? o)
|
||||
{
|
||||
BaseUrl = baseUrl.TrimEnd('/');
|
||||
_o = o ?? new ClientOptions();
|
||||
_http = _o.HttpClient ?? new HttpClient { Timeout = _o.Timeout };
|
||||
}
|
||||
|
||||
/// <summary>Content-Type < custom headers < per-call headers < bearer token.</summary>
|
||||
public async Task<(HttpResponseMessage resp, string body)> SendAsync(
|
||||
HttpMethod method, string url, string? json, IDictionary<string, string>? extra, CancellationToken ct)
|
||||
{
|
||||
using var req = new HttpRequestMessage(method, url);
|
||||
if (json != null) req.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
foreach (var (k, v) in _o.Headers) Set(req, k, v);
|
||||
if (extra != null) foreach (var (k, v) in extra) Set(req, k, v);
|
||||
if (!string.IsNullOrEmpty(_o.Token)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _o.Token);
|
||||
var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return (resp, body);
|
||||
}
|
||||
|
||||
static void Set(HttpRequestMessage req, string name, string value)
|
||||
{
|
||||
req.Headers.Remove(name);
|
||||
if (!req.Headers.TryAddWithoutValidation(name, value) && req.Content != null)
|
||||
{
|
||||
req.Content.Headers.Remove(name);
|
||||
req.Content.Headers.TryAddWithoutValidation(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
public static ResolveSpecException ErrorFrom(int status, string body, string? reason)
|
||||
{
|
||||
ApiError? err = null;
|
||||
var isJson = false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
isJson = true;
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.Object)
|
||||
err = e.Deserialize<ApiError>();
|
||||
}
|
||||
catch (JsonException) { }
|
||||
|
||||
var message = err?.Message;
|
||||
if (string.IsNullOrEmpty(message))
|
||||
{
|
||||
var text = isJson ? "" : body.Trim();
|
||||
if (text.Length > 200) text = text[..200];
|
||||
message = text.Length > 0 ? text : $"{reason ?? "Error"} ({status})";
|
||||
}
|
||||
return new ResolveSpecException(message, status, err);
|
||||
}
|
||||
|
||||
public static string Segment(string s) => Uri.EscapeDataString(s);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ResolveSpec</RootNamespace>
|
||||
<PackageId>ResolveSpec.Client</PackageId>
|
||||
<Version>0.1.0</Version>
|
||||
<Description>Client for ResolveSpec (JSON body) and FunctionSpec endpoints</Description>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ResolveSpec;
|
||||
|
||||
/// <summary>Client for the ResolveSpec JSON body protocol: POST {operation, data, options}.</summary>
|
||||
public sealed class ResolveSpecClient
|
||||
{
|
||||
static readonly JsonSerializerOptions Json = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
||||
readonly Transport _t;
|
||||
|
||||
public ResolveSpecClient(string baseUrl, ClientOptions? options = null) => _t = new Transport(baseUrl, options);
|
||||
|
||||
sealed class Request
|
||||
{
|
||||
[JsonPropertyName("operation")] public string Operation { get; set; } = "";
|
||||
[JsonPropertyName("id")] public string[]? Id { get; set; }
|
||||
[JsonPropertyName("data")] public object? Data { get; set; }
|
||||
[JsonPropertyName("options")] public Options? Options { get; set; }
|
||||
}
|
||||
|
||||
// A single id (int/long/string) goes in the URL; string[] / IEnumerable<string> goes in the body.
|
||||
static string? UrlId(object? id) => id switch
|
||||
{
|
||||
null => null,
|
||||
string s => s,
|
||||
IEnumerable<string> => null,
|
||||
_ => Convert.ToString(id, System.Globalization.CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
static string[]? BodyId(object? id) => id is IEnumerable<string> e and not string ? e.ToArray() : null;
|
||||
|
||||
string Url(string schema, string entity, string? id)
|
||||
{
|
||||
var u = $"{_t.BaseUrl}/{Transport.Segment(schema)}/{Transport.Segment(entity)}";
|
||||
return string.IsNullOrEmpty(id) ? u : $"{u}/{Transport.Segment(id)}";
|
||||
}
|
||||
|
||||
async Task<Response> SendAsync(HttpMethod method, string url, Request? body, CancellationToken ct)
|
||||
{
|
||||
var json = body == null ? null : JsonSerializer.Serialize(body, Json);
|
||||
var (resp, text) = await _t.SendAsync(method, url, json, null, ct).ConfigureAwait(false);
|
||||
var status = (int)resp.StatusCode;
|
||||
if (!resp.IsSuccessStatusCode) throw Transport.ErrorFrom(status, text, resp.ReasonPhrase);
|
||||
var r = JsonSerializer.Deserialize<Response>(text, Json) ?? new Response();
|
||||
if (!r.Success && r.Error != null) throw new ResolveSpecException(r.Error.Message, status, r.Error);
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>GET /{schema}/{entity}</summary>
|
||||
public Task<Response> GetMetadataAsync(string schema, string entity, CancellationToken ct = default) =>
|
||||
SendAsync(HttpMethod.Get, Url(schema, entity, null), null, ct);
|
||||
|
||||
public Task<Response> ReadAsync(string schema, string entity, object? id = null, Options? options = null, CancellationToken ct = default) =>
|
||||
SendAsync(HttpMethod.Post, Url(schema, entity, UrlId(id)), new Request { Operation = "read", Id = BodyId(id), Options = options }, ct);
|
||||
|
||||
public Task<Response> CreateAsync(string schema, string entity, object data, Options? options = null, CancellationToken ct = default) =>
|
||||
SendAsync(HttpMethod.Post, Url(schema, entity, null), new Request { Operation = "create", Data = data, Options = options }, ct);
|
||||
|
||||
public Task<Response> UpdateAsync(string schema, string entity, object data, object? id = null, Options? options = null, CancellationToken ct = default) =>
|
||||
SendAsync(HttpMethod.Post, Url(schema, entity, UrlId(id)), new Request { Operation = "update", Id = BodyId(id), Data = data, Options = options }, ct);
|
||||
|
||||
public Task<Response> DeleteAsync(string schema, string entity, object id, CancellationToken ct = default) =>
|
||||
SendAsync(HttpMethod.Post, Url(schema, entity, UrlId(id)), new Request { Operation = "delete" }, ct);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ResolveSpec;
|
||||
|
||||
// Types aligned with Go pkg/common/types.go. JsonPropertyName values are the wire names.
|
||||
|
||||
public sealed class FilterOption
|
||||
{
|
||||
[JsonPropertyName("column")] public string Column { get; set; } = "";
|
||||
/// <summary>eq neq gt gte lt lte like ilike in contains startswith endswith between between_inclusive is_null is_not_null</summary>
|
||||
[JsonPropertyName("operator")] public string Operator { get; set; } = "eq";
|
||||
[JsonPropertyName("value")] public object? Value { get; set; }
|
||||
/// <summary>AND | OR</summary>
|
||||
[JsonPropertyName("logic_operator")] public string? LogicOperator { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SortOption
|
||||
{
|
||||
[JsonPropertyName("column")] public string Column { get; set; } = "";
|
||||
/// <summary>asc | desc</summary>
|
||||
[JsonPropertyName("direction")] public string Direction { get; set; } = "asc";
|
||||
}
|
||||
|
||||
public sealed class Parameter
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("value")] public string Value { get; set; } = "";
|
||||
[JsonPropertyName("sequence")] public int? Sequence { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CustomOperator
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("sql")] public string Sql { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ComputedColumn
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("expression")] public string Expression { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class PreloadOption
|
||||
{
|
||||
[JsonPropertyName("relation")] public string? Relation { get; set; }
|
||||
[JsonPropertyName("table_name")] public string? TableName { get; set; }
|
||||
[JsonPropertyName("columns")] public List<string>? Columns { get; set; }
|
||||
[JsonPropertyName("omit_columns")] public List<string>? OmitColumns { get; set; }
|
||||
[JsonPropertyName("sort")] public List<SortOption>? Sort { get; set; }
|
||||
[JsonPropertyName("filters")] public List<FilterOption>? Filters { get; set; }
|
||||
[JsonPropertyName("where")] public string? Where { get; set; }
|
||||
[JsonPropertyName("limit")] public int? Limit { get; set; }
|
||||
[JsonPropertyName("offset")] public int? Offset { get; set; }
|
||||
[JsonPropertyName("updateable")] public bool? Updateable { get; set; }
|
||||
[JsonPropertyName("computed_ql")] public Dictionary<string, string>? ComputedQl { get; set; }
|
||||
[JsonPropertyName("recursive")] public bool? Recursive { get; set; }
|
||||
[JsonPropertyName("primary_key")] public string? PrimaryKey { get; set; }
|
||||
[JsonPropertyName("related_key")] public string? RelatedKey { get; set; }
|
||||
[JsonPropertyName("foreign_key")] public string? ForeignKey { get; set; }
|
||||
[JsonPropertyName("recursive_child_key")] public string? RecursiveChildKey { get; set; }
|
||||
[JsonPropertyName("sql_joins")] public List<string>? SqlJoins { get; set; }
|
||||
[JsonPropertyName("join_aliases")] public List<string>? JoinAliases { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VectorSearchOption
|
||||
{
|
||||
[JsonPropertyName("column")] public string Column { get; set; } = "";
|
||||
[JsonPropertyName("vector")] public List<double> Vector { get; set; } = new();
|
||||
/// <summary>l2 (default) | cosine | ip</summary>
|
||||
[JsonPropertyName("metric")] public string? Metric { get; set; }
|
||||
/// <summary>Distance column alias, default _distance.</summary>
|
||||
[JsonPropertyName("as")] public string? As { get; set; }
|
||||
[JsonPropertyName("direction")] public string? Direction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>ResolveSpec request options object.</summary>
|
||||
public sealed class Options
|
||||
{
|
||||
[JsonPropertyName("preload")] public List<PreloadOption>? Preload { get; set; }
|
||||
[JsonPropertyName("columns")] public List<string>? Columns { get; set; }
|
||||
[JsonPropertyName("omit_columns")] public List<string>? OmitColumns { get; set; }
|
||||
[JsonPropertyName("filters")] public List<FilterOption>? Filters { get; set; }
|
||||
[JsonPropertyName("sort")] public List<SortOption>? Sort { get; set; }
|
||||
[JsonPropertyName("limit")] public int? Limit { get; set; }
|
||||
[JsonPropertyName("offset")] public int? Offset { get; set; }
|
||||
[JsonPropertyName("customOperators")] public List<CustomOperator>? CustomOperators { get; set; }
|
||||
[JsonPropertyName("computedColumns")] public List<ComputedColumn>? ComputedColumns { get; set; }
|
||||
[JsonPropertyName("parameters")] public List<Parameter>? Parameters { get; set; }
|
||||
[JsonPropertyName("cursor_forward")] public string? CursorForward { get; set; }
|
||||
[JsonPropertyName("cursor_backward")] public string? CursorBackward { get; set; }
|
||||
[JsonPropertyName("fetch_row_number")] public string? FetchRowNumber { get; set; }
|
||||
[JsonPropertyName("vector_search")] public VectorSearchOption? VectorSearch { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Metadata
|
||||
{
|
||||
[JsonPropertyName("total")] public long Total { get; set; }
|
||||
[JsonPropertyName("count")] public long Count { get; set; }
|
||||
[JsonPropertyName("filtered")] public long Filtered { get; set; }
|
||||
[JsonPropertyName("limit")] public long Limit { get; set; }
|
||||
[JsonPropertyName("offset")] public long Offset { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ApiError
|
||||
{
|
||||
[JsonPropertyName("code")] public string Code { get; set; } = "";
|
||||
[JsonPropertyName("message")] public string Message { get; set; } = "";
|
||||
[JsonPropertyName("details")] public JsonElement? Details { get; set; }
|
||||
/// <summary>Server-side reason (funcspec / restheadspec).</summary>
|
||||
[JsonPropertyName("detail")] public string? Detail { get; set; }
|
||||
[JsonPropertyName("sql")] public string? Sql { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>ResolveSpec envelope. <see cref="Data"/> is raw JSON; use <see cref="Decode{T}"/>.</summary>
|
||||
public sealed class Response
|
||||
{
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
[JsonPropertyName("data")] public JsonElement Data { get; set; }
|
||||
[JsonPropertyName("metadata")] public Metadata? Metadata { get; set; }
|
||||
[JsonPropertyName("error")] public ApiError? Error { get; set; }
|
||||
|
||||
public T? Decode<T>() => Data.ValueKind == JsonValueKind.Undefined ? default : Data.Deserialize<T>();
|
||||
}
|
||||
|
||||
/// <summary>Thrown on a non-2xx response or an unsuccessful API result.</summary>
|
||||
public sealed class ResolveSpecException : Exception
|
||||
{
|
||||
public int StatusCode { get; }
|
||||
public ApiError Error { get; }
|
||||
|
||||
public ResolveSpecException(string message, int statusCode, ApiError? error = null) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Error = error ?? new ApiError { Message = message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ResolveSpec;
|
||||
using Xunit;
|
||||
|
||||
public class Stub : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? Request;
|
||||
public string Body = "";
|
||||
readonly HttpStatusCode _status;
|
||||
readonly string _json;
|
||||
readonly Dictionary<string, string> _headers;
|
||||
|
||||
public Stub(HttpStatusCode status, string json, Dictionary<string, string>? headers = null)
|
||||
{
|
||||
_status = status; _json = json; _headers = headers ?? new();
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
Request = request;
|
||||
Body = request.Content == null ? "" : await request.Content.ReadAsStringAsync(ct);
|
||||
var r = new HttpResponseMessage(_status) { Content = new StringContent(_json, Encoding.UTF8, "application/json") };
|
||||
foreach (var (k, v) in _headers) r.Headers.TryAddWithoutValidation(k, v);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
public class ResolveSpecTests
|
||||
{
|
||||
static (ResolveSpecClient, Stub) Make(HttpStatusCode s, string json)
|
||||
{
|
||||
var stub = new Stub(s, json);
|
||||
var o = new ClientOptions { Token = "tok", HttpClient = new HttpClient(stub) };
|
||||
o.Headers["X-Tenant"] = "a";
|
||||
return (new ResolveSpecClient("http://localhost:3000/", o), stub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPostsBody()
|
||||
{
|
||||
var (c, s) = Make(HttpStatusCode.OK, """{"success":true,"data":[{"id":1}]}""");
|
||||
var r = await c.ReadAsync("public", "users", null, new Options { Limit = 5, Filters = new() { new FilterOption { Column = "a", Operator = "eq", Value = 1 } } });
|
||||
Assert.Equal(HttpMethod.Post, s.Request!.Method);
|
||||
Assert.Equal("/public/users", s.Request.RequestUri!.AbsolutePath);
|
||||
Assert.Equal("Bearer tok", s.Request.Headers.Authorization!.ToString());
|
||||
Assert.Equal("a", s.Request.Headers.GetValues("X-Tenant").Single());
|
||||
using var body = JsonDocument.Parse(s.Body);
|
||||
Assert.Equal("read", body.RootElement.GetProperty("operation").GetString());
|
||||
Assert.Equal(5, body.RootElement.GetProperty("options").GetProperty("limit").GetInt32());
|
||||
Assert.False(body.RootElement.TryGetProperty("id", out _));
|
||||
Assert.Single(r.Decode<List<Dictionary<string, int>>>()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdPlacement()
|
||||
{
|
||||
var (c, s) = Make(HttpStatusCode.OK, """{"success":true,"data":{}}""");
|
||||
await c.ReadAsync("s", "e", 7);
|
||||
Assert.Equal("/s/e/7", s.Request!.RequestUri!.AbsolutePath);
|
||||
await c.UpdateAsync("s", "e", new { a = 1 }, new[] { "1", "2" });
|
||||
Assert.Equal("/s/e", s.Request!.RequestUri!.AbsolutePath);
|
||||
using (var b = JsonDocument.Parse(s.Body))
|
||||
{
|
||||
Assert.Equal(2, b.RootElement.GetProperty("id").GetArrayLength());
|
||||
Assert.Equal("update", b.RootElement.GetProperty("operation").GetString());
|
||||
}
|
||||
await c.DeleteAsync("s", "e", "a/b");
|
||||
Assert.Equal("/s/e/a%2Fb", s.Request!.RequestUri!.AbsoluteUri[(s.Request.RequestUri.AbsoluteUri.IndexOf("/s/e", StringComparison.Ordinal))..]);
|
||||
Assert.Contains("\"delete\"", s.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Errors()
|
||||
{
|
||||
var (c, _) = Make(HttpStatusCode.BadRequest, """{"success":false,"error":{"code":"x","message":"bad","detail":"why"}}""");
|
||||
var e = await Assert.ThrowsAsync<ResolveSpecException>(() => c.ReadAsync("s", "e"));
|
||||
Assert.Equal((400, "x", "bad", "why"), (e.StatusCode, e.Error.Code, e.Message, e.Error.Detail));
|
||||
|
||||
var (c2, _) = Make(HttpStatusCode.BadGateway, "bad gateway");
|
||||
var e2 = await Assert.ThrowsAsync<ResolveSpecException>(() => c2.ReadAsync("s", "e"));
|
||||
Assert.Equal((502, "bad gateway"), (e2.StatusCode, e2.Message));
|
||||
|
||||
var (c3, _) = Make(HttpStatusCode.OK, """{"success":false,"error":{"code":"c","message":"nope"}}""");
|
||||
var e3 = await Assert.ThrowsAsync<ResolveSpecException>(() => c3.ReadAsync("s", "e"));
|
||||
Assert.Equal("nope", e3.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class FuncSpecTests
|
||||
{
|
||||
[Fact]
|
||||
public void HeaderFilters()
|
||||
{
|
||||
var h = FuncSpecClient.BuildHeaders(new FuncSpecOptions
|
||||
{
|
||||
Filters = new()
|
||||
{
|
||||
new() { Column = "status", Operator = "eq", Value = "active" },
|
||||
new() { Column = "age", Operator = "gte", Value = 18 },
|
||||
new() { Column = "name", Operator = "contains", Value = "x", LogicOperator = "OR" },
|
||||
new() { Column = "deleted", Operator = "is_null" },
|
||||
new() { Column = "id", Operator = "in", Value = new[] { 1, 2 } },
|
||||
new() { Column = "p", Operator = "between_inclusive", Value = new[] { 1, 5 } },
|
||||
},
|
||||
});
|
||||
Assert.Equal(new Dictionary<string, string>
|
||||
{
|
||||
["X-FieldFilter-status"] = "active",
|
||||
["X-SearchOp-greaterthanorequal-age"] = "18",
|
||||
["X-SearchOr-contains-name"] = "x",
|
||||
["X-SearchOp-empty-deleted"] = "",
|
||||
["X-SearchOp-in-id"] = "1,2",
|
||||
["X-SearchOp-betweeninclusive-p"] = "1,5",
|
||||
}, h);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderMiscAndEncoding()
|
||||
{
|
||||
var h = FuncSpecClient.BuildHeaders(new FuncSpecOptions
|
||||
{
|
||||
SearchFilters = new() { ["name"] = "bob" }, CustomSqlWhere = "a = 1", CustomSqlOr = "b = 2",
|
||||
Sort = new() { new() { Column = "name", Direction = "asc" }, new() { Column = "created_at", Direction = "DESC" } },
|
||||
Limit = 5, Offset = 10, Distinct = true, SkipCount = true, SkipCache = false, ResponseFormat = "syncfusion",
|
||||
});
|
||||
Assert.Equal("name ASC,created_at DESC", h["X-Sort"]);
|
||||
Assert.Equal("bob", h["X-SearchFilter-name"]);
|
||||
Assert.Equal("a = 1", h["X-Custom-SQL-W"]);
|
||||
Assert.Equal("false", h["X-SkipCache"]);
|
||||
Assert.Equal("true", h["X-Syncfusion"]);
|
||||
|
||||
h = FuncSpecClient.BuildHeaders(new FuncSpecOptions { Filters = new()
|
||||
{
|
||||
new() { Column = "n", Operator = "eq", Value = "héllo" },
|
||||
new() { Column = "m", Operator = "eq", Value = " pad" },
|
||||
} });
|
||||
Assert.StartsWith("ZIP_", h["X-FieldFilter-n"]);
|
||||
Assert.Equal("héllo", FuncSpecClient.DecodeHeaderValue(h["X-FieldFilter-n"]));
|
||||
Assert.Equal(" pad", FuncSpecClient.DecodeHeaderValue(h["X-FieldFilter-m"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryBuilding()
|
||||
{
|
||||
var q = FuncSpecClient.BuildQuery(new Dictionary<string, object?> { ["a"] = true, ["b"] = new[] { "x", "y" }, ["c"] = null, ["d"] = 3 });
|
||||
Assert.Equal(new[] { "a=true", "b=x", "b=y", "d=3" }, q.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryListMetadata()
|
||||
{
|
||||
var stub = new Stub((HttpStatusCode)206, """[{"id":1},{"id":2}]""", new() { ["Content-Range"] = "items 10-12/50" });
|
||||
var c = new FuncSpecClient("http://x", new ClientOptions { Token = "tok", HttpClient = new HttpClient(stub) });
|
||||
var r = await c.QueryListAsync("/api/users", new Dictionary<string, object?> { ["org"] = 1 }, new FuncSpecOptions { Limit = 2 });
|
||||
Assert.Equal("GET", stub.Request!.Method.Method);
|
||||
Assert.Equal("/api/users", stub.Request.RequestUri!.AbsolutePath);
|
||||
Assert.Equal("?org=1", stub.Request.RequestUri.Query);
|
||||
Assert.Equal("2", stub.Request.Headers.GetValues("X-Limit").Single());
|
||||
Assert.Equal((50L, 2L, 50L, 2L, 10L), (r.Metadata!.Total, r.Metadata.Count, r.Metadata.Filtered, r.Metadata.Limit, r.Metadata.Offset));
|
||||
Assert.Equal(2, r.Data.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuerySingleAndError()
|
||||
{
|
||||
var ok = new FuncSpecClient("http://x", new ClientOptions { HttpClient = new HttpClient(new Stub(HttpStatusCode.OK, """{"id":1}""")) });
|
||||
var r = await ok.QueryAsync("api/u");
|
||||
Assert.Null(r.Metadata);
|
||||
Assert.Equal(1, r.Data.GetProperty("id").GetInt32());
|
||||
|
||||
var bad = new FuncSpecClient("http://x", new ClientOptions { HttpClient = new HttpClient(new Stub(HttpStatusCode.BadRequest,
|
||||
"""{"success":false,"error":{"code":"hook_error","message":"Hook execution failed","detail":"authentication required"}}""")) });
|
||||
var e = await Assert.ThrowsAsync<ResolveSpecException>(() => bad.QueryAsync("api/u"));
|
||||
Assert.Equal(("hook_error", "authentication required"), (e.Error.Code, e.Error.Detail));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../src/ResolveSpec.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user