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
@@ -0,0 +1,7 @@
/// Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
library;
export 'src/client.dart' show ClientOptions, ResolveSpecException;
export 'src/funcspec.dart';
export 'src/resolvespec.dart';
export 'src/types.dart';
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'types.dart';
/// Thrown on a non-2xx response or an unsuccessful API result.
class ResolveSpecException implements Exception {
final int statusCode;
final String message;
final ApiError error;
ResolveSpecException(this.message, this.statusCode, [ApiError? error]) : error = error ?? ApiError(message: message);
@override
String toString() => 'ResolveSpecException($statusCode): $message';
}
/// Shared HTTP configuration for both clients.
class ClientOptions {
final String? token;
final Map<String, String> headers;
final Duration timeout;
/// Supply your own client (tests, pooling).
final http.Client? httpClient;
const ClientOptions({this.token, this.headers = const {}, this.timeout = const Duration(seconds: 30), this.httpClient});
}
class Transport {
final String baseUrl;
final ClientOptions options;
final http.Client _http;
Transport(String baseUrl, ClientOptions? options)
: baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), ''),
options = options ?? const ClientOptions(),
_http = options?.httpClient ?? http.Client();
/// Content-Type < custom headers < per-call headers < bearer token.
Future<http.Response> send(String method, Uri uri, {String? body, Map<String, String>? extra}) {
final headers = <String, String>{'Content-Type': 'application/json'};
void merge(Map<String, String> src) {
for (final e in src.entries) {
headers.removeWhere((k, _) => k.toLowerCase() == e.key.toLowerCase());
headers[e.key] = e.value;
}
}
merge(options.headers);
if (extra != null) merge(extra);
final token = options.token;
if (token != null && token.isNotEmpty) merge({'Authorization': 'Bearer $token'});
final req = http.Request(method, uri)..headers.addAll(headers);
if (body != null) req.body = body;
return _http.send(req).timeout(options.timeout).then(http.Response.fromStream);
}
void close() => _http.close();
static ResolveSpecException errorFrom(http.Response resp) {
final body = utf8.decode(resp.bodyBytes, allowMalformed: true);
ApiError? err;
var isJson = false;
try {
final parsed = jsonDecode(body);
isJson = true;
if (parsed is Map<String, dynamic> && parsed['error'] is Map<String, dynamic>) {
err = ApiError.fromJson(parsed['error'] as Map<String, dynamic>);
}
} on FormatException {
// not JSON
}
var message = err?.message ?? '';
if (message.isEmpty) {
var text = isJson ? '' : body.trim();
if (text.length > 200) text = text.substring(0, 200);
message = text.isNotEmpty ? text : '${resp.reasonPhrase ?? 'Error'} (${resp.statusCode})';
}
return ResolveSpecException(message, resp.statusCode, err);
}
}
@@ -0,0 +1,186 @@
import 'dart:convert';
import 'client.dart';
import 'types.dart';
/// Options sent to funcspec endpoints as X-* headers.
///
/// Server behaviour (pkg/funcspec): [sort] is inserted raw into ORDER BY (so it is sent as SQL
/// terms); only one search operator per column is kept; values starting with `ZIP_` or `__`
/// are base64-decoded by the server, so such plaintext values cannot be sent faithfully.
class FuncSpecOptions {
/// eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr.
final List<FilterOption>? filters;
/// X-SearchFilter-{col}: text ILIKE.
final Map<String, String>? searchFilters;
final String? customSqlWhere;
final String? customSqlOr;
final List<SortOption>? sort;
final int? limit;
final int? offset;
final bool? distinct;
final bool? skipCount;
final bool? skipCache;
/// simple | detail | syncfusion
final String? responseFormat;
const FuncSpecOptions({
this.filters,
this.searchFilters,
this.customSqlWhere,
this.customSqlOr,
this.sort,
this.limit,
this.offset,
this.distinct,
this.skipCount,
this.skipCache,
this.responseFormat,
});
}
const _operatorMap = {
'eq': 'equals',
'neq': 'notequals',
'gt': 'greaterthan',
'gte': 'greaterthanorequal',
'lt': 'lessthan',
'lte': 'lessthanorequal',
'like': 'contains',
'ilike': 'contains',
'contains': 'contains',
'startswith': 'beginswith',
'endswith': 'endswith',
'in': 'in',
'between': 'between',
'between_inclusive': 'betweeninclusive',
'is_null': 'empty',
'is_not_null': 'notempty',
};
String _scalar(Object? v) => v == null ? '' : v.toString();
String _filterValue(Object? v) => v is Iterable ? v.map(_scalar).join(',') : _scalar(v);
/// Base64 (UTF-8) with the `ZIP_` prefix.
String encodeHeaderValue(String v) => 'ZIP_${base64.encode(utf8.encode(v))}';
/// Decode a value that may carry a `ZIP_` or `__` prefix (nested allowed).
String decodeHeaderValue(String v) {
for (final p in const ['ZIP_', '__']) {
if (v.startsWith(p)) {
var b64 = v.substring(p.length).replaceAll(RegExp(r'[\n\r ]'), '');
b64 = b64.padRight(b64.length + (4 - b64.length % 4) % 4, '=');
try {
return decodeHeaderValue(utf8.decode(base64.decode(b64)));
} on FormatException {
return v;
}
}
}
return v;
}
/// Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).
String _safe(String v) {
final unsafe = v != v.trim() || v.runes.any((c) => c > 127 || c < 32 || c == 127);
return unsafe ? encodeHeaderValue(v) : v;
}
/// Build the X-* headers understood by funcspec.ParseParameters.
Map<String, String> buildHeaders(FuncSpecOptions? o) {
final h = <String, String>{};
if (o == null) return h;
for (final f in o.filters ?? const <FilterOption>[]) {
final logic = f.logicOperator ?? 'AND';
final v = _safe(_filterValue(f.value));
if (f.operator == 'eq' && logic == 'AND') {
h['X-FieldFilter-${f.column}'] = v;
} else {
final kind = logic == 'OR' ? 'X-SearchOr' : 'X-SearchOp';
h['$kind-${_operatorMap[f.operator] ?? f.operator}-${f.column}'] = v;
}
}
o.searchFilters?.forEach((col, text) => h['X-SearchFilter-$col'] = _safe(text));
if (o.customSqlWhere != null && o.customSqlWhere!.isNotEmpty) h['X-Custom-SQL-W'] = _safe(o.customSqlWhere!);
if (o.customSqlOr != null && o.customSqlOr!.isNotEmpty) h['X-Custom-SQL-Or'] = _safe(o.customSqlOr!);
if (o.sort != null && o.sort!.isNotEmpty) {
// funcspec puts this verbatim into ORDER BY
h['X-Sort'] = _safe(o.sort!.map((s) => '${s.column} ${s.direction.toLowerCase() == 'desc' ? 'DESC' : 'ASC'}').join(','));
}
if (o.limit != null) h['X-Limit'] = '${o.limit}';
if (o.offset != null) h['X-Offset'] = '${o.offset}';
if (o.distinct != null) h['X-Distinct'] = '${o.distinct}';
if (o.skipCount != null) h['X-SkipCount'] = '${o.skipCount}';
if (o.skipCache != null) h['X-SkipCache'] = '${o.skipCache}';
switch (o.responseFormat) {
case 'simple':
h['X-SimpleApi'] = 'true';
case 'detail':
h['X-DetailApi'] = 'true';
case 'syncfusion':
h['X-Syncfusion'] = 'true';
}
return h;
}
/// Build query-string pairs: lists -> repeated keys, null skipped, bools -> true/false.
Map<String, List<String>> buildQuery(Map<String, Object?>? params) {
final out = <String, List<String>>{};
params?.forEach((k, v) {
if (v == null) return;
out[k] = v is Iterable ? v.map((e) => _safe(_scalar(e))).toList() : [_safe(_scalar(v))];
});
return out;
}
final _contentRange = RegExp(r'(\d+)-(\d+)/(\d+)');
Metadata _metadata(String? contentRange, FuncSpecOptions? o) {
final m = _contentRange.firstMatch(contentRange ?? '');
if (m == null) return Metadata(limit: o?.limit ?? 0);
final start = int.parse(m.group(1)!);
final end = int.parse(m.group(2)!);
final total = int.parse(m.group(3)!);
return Metadata(total: total, count: end - start, filtered: total, limit: o?.limit ?? 0, offset: start);
}
/// Client for user-defined SQL endpoints. Routes are defined by the server application.
class FuncSpecClient {
final Transport _t;
FuncSpecClient(String baseUrl, [ClientOptions? options]) : _t = Transport(baseUrl, options);
void close() => _t.close();
Future<Response> _call(String method, String path, Map<String, Object?>? params, FuncSpecOptions? o, bool list) async {
final base = Uri.parse('${_t.baseUrl}/${path.replaceAll(RegExp(r'^/+'), '')}');
final pairs = <String>[];
buildQuery(params).forEach((k, vs) {
for (final v in vs) {
pairs.add('${Uri.encodeQueryComponent(k)}=${Uri.encodeQueryComponent(v)}');
}
});
final uri = pairs.isEmpty ? base : base.replace(query: pairs.join('&'));
final resp = await _t.send(method, uri, extra: buildHeaders(o));
if (resp.statusCode < 200 || resp.statusCode > 299) throw Transport.errorFrom(resp); // 206 is success
final text = utf8.decode(resp.bodyBytes);
return Response(
success: true,
data: text.trim().isEmpty ? null : jsonDecode(text),
metadata: list ? _metadata(resp.headers['content-range'], o) : null,
);
}
/// Single-record endpoint (SqlQuery). `data` is the row object.
Future<Response> query(String path, {Map<String, Object?>? params, FuncSpecOptions? options, String method = 'GET'}) =>
_call(method.toUpperCase(), path, params, options, false);
/// List endpoint (SqlQueryList). Metadata comes from Content-Range.
Future<Response> queryList(String path, {Map<String, Object?>? params, FuncSpecOptions? options, String method = 'GET'}) =>
_call(method.toUpperCase(), path, params, options, true);
}
@@ -0,0 +1,63 @@
import 'dart:convert';
import 'client.dart';
import 'types.dart';
/// Client for the ResolveSpec JSON body protocol: POST {operation, data, options}.
///
/// A record `id` of type `int` or `String` goes in the URL; a `List<String>` goes in the body.
class ResolveSpecClient {
final Transport _t;
ResolveSpecClient(String baseUrl, [ClientOptions? options]) : _t = Transport(baseUrl, options);
void close() => _t.close();
static String? _urlId(Object? id) => id == null || id is List ? null : id.toString();
static List<String>? _bodyId(Object? id) => id is List ? id.map((e) => e.toString()).toList() : null;
Uri _url(String schema, String entity, String? id) {
var u = '${_t.baseUrl}/${Uri.encodeComponent(schema)}/${Uri.encodeComponent(entity)}';
if (id != null && id.isNotEmpty) u += '/${Uri.encodeComponent(id)}';
return Uri.parse(u);
}
Future<Response> _send(String method, Uri url, Map<String, dynamic>? body) async {
final resp = await _t.send(method, url, body: body == null ? null : jsonEncode(body));
if (resp.statusCode < 200 || resp.statusCode > 299) throw Transport.errorFrom(resp);
final decoded = jsonDecode(utf8.decode(resp.bodyBytes));
final r = Response.fromJson(decoded as Map<String, dynamic>);
if (!r.success && r.error != null) throw ResolveSpecException(r.error!.message, resp.statusCode, r.error);
return r;
}
/// GET /{schema}/{entity}
Future<Response> getMetadata(String schema, String entity) => _send('GET', _url(schema, entity, null), null);
Future<Response> read(String schema, String entity, {Object? id, Options? options}) => _send(
'POST',
_url(schema, entity, _urlId(id)),
{'operation': 'read', if (_bodyId(id) != null) 'id': _bodyId(id), if (options != null) 'options': options.toJson()},
);
Future<Response> create(String schema, String entity, Object data, {Options? options}) => _send(
'POST',
_url(schema, entity, null),
{'operation': 'create', 'data': data, if (options != null) 'options': options.toJson()},
);
Future<Response> update(String schema, String entity, Object data, {Object? id, Options? options}) => _send(
'POST',
_url(schema, entity, _urlId(id)),
{
'operation': 'update',
if (_bodyId(id) != null) 'id': _bodyId(id),
'data': data,
if (options != null) 'options': options.toJson(),
},
);
Future<Response> delete(String schema, String entity, Object id) =>
_send('POST', _url(schema, entity, _urlId(id)), {'operation': 'delete'});
}
+268
View File
@@ -0,0 +1,268 @@
// Types aligned with Go pkg/common/types.go. toJson() emits the wire names.
Map<String, dynamic> _compact(Map<String, dynamic> m) {
m.removeWhere((_, v) => v == null);
return m;
}
class FilterOption {
final String column;
/// eq neq gt gte lt lte like ilike in contains startswith endswith between
/// between_inclusive is_null is_not_null
final String operator;
final Object? value;
/// AND | OR
final String? logicOperator;
const FilterOption(this.column, this.operator, [this.value, this.logicOperator]);
Map<String, dynamic> toJson() => _compact({
'column': column,
'operator': operator,
'value': value,
'logic_operator': logicOperator,
});
}
class SortOption {
final String column;
/// asc | desc
final String direction;
const SortOption(this.column, [this.direction = 'asc']);
Map<String, dynamic> toJson() => {'column': column, 'direction': direction};
}
class Parameter {
final String name;
final String value;
final int? sequence;
const Parameter(this.name, this.value, [this.sequence]);
Map<String, dynamic> toJson() => _compact({'name': name, 'value': value, 'sequence': sequence});
}
class CustomOperator {
final String name;
final String sql;
const CustomOperator(this.name, this.sql);
Map<String, dynamic> toJson() => {'name': name, 'sql': sql};
}
class ComputedColumn {
final String name;
final String expression;
const ComputedColumn(this.name, this.expression);
Map<String, dynamic> toJson() => {'name': name, 'expression': expression};
}
class PreloadOption {
final String? relation;
final String? tableName;
final List<String>? columns;
final List<String>? omitColumns;
final List<SortOption>? sort;
final List<FilterOption>? filters;
final String? where;
final int? limit;
final int? offset;
final bool? updateable;
final Map<String, String>? computedQl;
final bool? recursive;
final String? primaryKey;
final String? relatedKey;
final String? foreignKey;
final String? recursiveChildKey;
final List<String>? sqlJoins;
final List<String>? joinAliases;
const PreloadOption({
this.relation,
this.tableName,
this.columns,
this.omitColumns,
this.sort,
this.filters,
this.where,
this.limit,
this.offset,
this.updateable,
this.computedQl,
this.recursive,
this.primaryKey,
this.relatedKey,
this.foreignKey,
this.recursiveChildKey,
this.sqlJoins,
this.joinAliases,
});
Map<String, dynamic> toJson() => _compact({
'relation': relation,
'table_name': tableName,
'columns': columns,
'omit_columns': omitColumns,
'sort': sort?.map((e) => e.toJson()).toList(),
'filters': filters?.map((e) => e.toJson()).toList(),
'where': where,
'limit': limit,
'offset': offset,
'updateable': updateable,
'computed_ql': computedQl,
'recursive': recursive,
'primary_key': primaryKey,
'related_key': relatedKey,
'foreign_key': foreignKey,
'recursive_child_key': recursiveChildKey,
'sql_joins': sqlJoins,
'join_aliases': joinAliases,
});
}
class VectorSearchOption {
final String column;
final List<double> vector;
/// l2 (default) | cosine | ip
final String? metric;
/// Distance column alias, default _distance.
final String? as;
final String? direction;
const VectorSearchOption(this.column, this.vector, {this.metric, this.as, this.direction});
Map<String, dynamic> toJson() =>
_compact({'column': column, 'vector': vector, 'metric': metric, 'as': as, 'direction': direction});
}
/// ResolveSpec request options object.
class Options {
final List<PreloadOption>? preload;
final List<String>? columns;
final List<String>? omitColumns;
final List<FilterOption>? filters;
final List<SortOption>? sort;
final int? limit;
final int? offset;
final List<CustomOperator>? customOperators;
final List<ComputedColumn>? computedColumns;
final List<Parameter>? parameters;
final String? cursorForward;
final String? cursorBackward;
final String? fetchRowNumber;
final VectorSearchOption? vectorSearch;
const Options({
this.preload,
this.columns,
this.omitColumns,
this.filters,
this.sort,
this.limit,
this.offset,
this.customOperators,
this.computedColumns,
this.parameters,
this.cursorForward,
this.cursorBackward,
this.fetchRowNumber,
this.vectorSearch,
});
Map<String, dynamic> toJson() => _compact({
'preload': preload?.map((e) => e.toJson()).toList(),
'columns': columns,
'omit_columns': omitColumns,
'filters': filters?.map((e) => e.toJson()).toList(),
'sort': sort?.map((e) => e.toJson()).toList(),
'limit': limit,
'offset': offset,
'customOperators': customOperators?.map((e) => e.toJson()).toList(),
'computedColumns': computedColumns?.map((e) => e.toJson()).toList(),
'parameters': parameters?.map((e) => e.toJson()).toList(),
'cursor_forward': cursorForward,
'cursor_backward': cursorBackward,
'fetch_row_number': fetchRowNumber,
'vector_search': vectorSearch?.toJson(),
});
}
class Metadata {
final int total;
final int count;
final int filtered;
final int limit;
final int offset;
const Metadata({this.total = 0, this.count = 0, this.filtered = 0, this.limit = 0, this.offset = 0});
factory Metadata.fromJson(Map<String, dynamic> j) => Metadata(
total: (j['total'] as num?)?.toInt() ?? 0,
count: (j['count'] as num?)?.toInt() ?? 0,
filtered: (j['filtered'] as num?)?.toInt() ?? 0,
limit: (j['limit'] as num?)?.toInt() ?? 0,
offset: (j['offset'] as num?)?.toInt() ?? 0,
);
@override
bool operator ==(Object other) =>
other is Metadata &&
other.total == total &&
other.count == count &&
other.filtered == filtered &&
other.limit == limit &&
other.offset == offset;
@override
int get hashCode => Object.hash(total, count, filtered, limit, offset);
@override
String toString() => 'Metadata(total: $total, count: $count, filtered: $filtered, limit: $limit, offset: $offset)';
}
class ApiError {
final String code;
final String message;
final Object? details;
/// Server-side reason (funcspec / restheadspec).
final String? detail;
final String? sql;
const ApiError({this.code = '', this.message = '', this.details, this.detail, this.sql});
factory ApiError.fromJson(Map<String, dynamic> j) => ApiError(
code: (j['code'] as String?) ?? '',
message: (j['message'] as String?) ?? '',
details: j['details'],
detail: j['detail'] as String?,
sql: j['sql'] as String?,
);
}
/// ResolveSpec envelope. [data] is the decoded JSON value (Map, List or scalar).
class Response {
final bool success;
final Object? data;
final Metadata? metadata;
final ApiError? error;
const Response({required this.success, this.data, this.metadata, this.error});
factory Response.fromJson(Map<String, dynamic> j) => Response(
success: j['success'] == true,
data: j['data'],
metadata: j['metadata'] is Map<String, dynamic> ? Metadata.fromJson(j['metadata'] as Map<String, dynamic>) : null,
error: j['error'] is Map<String, dynamic> ? ApiError.fromJson(j['error'] as Map<String, dynamic>) : null,
);
}