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

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