mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 12:31:59 +00:00
feat(clients): add Go, Rust, C# and Dart clients for ResolveSpec and FunctionSpec
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.dart_tool/
|
||||
pubspec.lock
|
||||
build/
|
||||
@@ -0,0 +1,43 @@
|
||||
# resolvespec (Dart)
|
||||
|
||||
Dart / Flutter client for ResolveSpec (JSON body) and FunctionSpec. Depends on `package:http`. Dart >= 3.3.
|
||||
|
||||
> Not compiled or tested yet (no Dart SDK was available). Run `dart pub get && dart test` first.
|
||||
|
||||
## Clients
|
||||
|
||||
| Type | Constructor | Methods |
|
||||
|---|---|---|
|
||||
| `ResolveSpecClient` | `(baseUrl, [ClientOptions])` | `getMetadata` `read` `create` `update` `delete` `close` |
|
||||
| `FuncSpecClient` | `(baseUrl, [ClientOptions])` | `query` `queryList` `close` |
|
||||
|
||||
`ClientOptions(token:, headers:, timeout:, httpClient:)`. Precedence: Content-Type < custom headers < bearer token.
|
||||
|
||||
## ResolveSpec
|
||||
|
||||
- `id`: `int`/`String` → URL, `List<String>` → body. Named args: `id:`, `options:`.
|
||||
- `Options`, `FilterOption(column, operator, [value, logic])`, `SortOption(column, [direction])`.
|
||||
- Result: `Response{success, data (decoded JSON), metadata}`.
|
||||
|
||||
## FunctionSpec
|
||||
|
||||
- Routes are server-defined: pass the `path`.
|
||||
- `params:` map → query string (list → repeated keys, null skipped).
|
||||
- `FuncSpecOptions` → `X-*` headers: `filters`, `searchFilters`, `customSqlWhere`, `customSqlOr`, `sort`, `limit`, `offset`, `distinct`, `skipCount`, `skipCache`, `responseFormat`.
|
||||
- `queryList` fills `metadata` from `Content-Range`; 206 is success.
|
||||
- Helpers: `buildHeaders`, `buildQuery`, `encodeHeaderValue`, `decodeHeaderValue`.
|
||||
|
||||
## Server quirks
|
||||
|
||||
- `sort` is raw SQL in ORDER BY (client sends `col ASC|DESC`).
|
||||
- One search operator per column.
|
||||
- Values starting `ZIP_` / `__` are base64-decoded by the server.
|
||||
- Non-ASCII, control chars and edge spaces are auto-encoded (`ZIP_`).
|
||||
|
||||
## Errors
|
||||
|
||||
`ResolveSpecException{statusCode, message, error: ApiError{code, detail, sql}}`.
|
||||
|
||||
## Test
|
||||
|
||||
`dart test`
|
||||
@@ -0,0 +1 @@
|
||||
include: package:lints/recommended.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
/// Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
|
||||
library;
|
||||
|
||||
export 'src/client.dart' show ClientOptions, ResolveSpecException;
|
||||
export 'src/funcspec.dart';
|
||||
export 'src/resolvespec.dart';
|
||||
export 'src/types.dart';
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'types.dart';
|
||||
|
||||
/// Thrown on a non-2xx response or an unsuccessful API result.
|
||||
class ResolveSpecException implements Exception {
|
||||
final int statusCode;
|
||||
final String message;
|
||||
final ApiError error;
|
||||
|
||||
ResolveSpecException(this.message, this.statusCode, [ApiError? error]) : error = error ?? ApiError(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'ResolveSpecException($statusCode): $message';
|
||||
}
|
||||
|
||||
/// Shared HTTP configuration for both clients.
|
||||
class ClientOptions {
|
||||
final String? token;
|
||||
final Map<String, String> headers;
|
||||
final Duration timeout;
|
||||
|
||||
/// Supply your own client (tests, pooling).
|
||||
final http.Client? httpClient;
|
||||
|
||||
const ClientOptions({this.token, this.headers = const {}, this.timeout = const Duration(seconds: 30), this.httpClient});
|
||||
}
|
||||
|
||||
class Transport {
|
||||
final String baseUrl;
|
||||
final ClientOptions options;
|
||||
final http.Client _http;
|
||||
|
||||
Transport(String baseUrl, ClientOptions? options)
|
||||
: baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), ''),
|
||||
options = options ?? const ClientOptions(),
|
||||
_http = options?.httpClient ?? http.Client();
|
||||
|
||||
/// Content-Type < custom headers < per-call headers < bearer token.
|
||||
Future<http.Response> send(String method, Uri uri, {String? body, Map<String, String>? extra}) {
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
void merge(Map<String, String> src) {
|
||||
for (final e in src.entries) {
|
||||
headers.removeWhere((k, _) => k.toLowerCase() == e.key.toLowerCase());
|
||||
headers[e.key] = e.value;
|
||||
}
|
||||
}
|
||||
|
||||
merge(options.headers);
|
||||
if (extra != null) merge(extra);
|
||||
final token = options.token;
|
||||
if (token != null && token.isNotEmpty) merge({'Authorization': 'Bearer $token'});
|
||||
|
||||
final req = http.Request(method, uri)..headers.addAll(headers);
|
||||
if (body != null) req.body = body;
|
||||
return _http.send(req).timeout(options.timeout).then(http.Response.fromStream);
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
|
||||
static ResolveSpecException errorFrom(http.Response resp) {
|
||||
final body = utf8.decode(resp.bodyBytes, allowMalformed: true);
|
||||
ApiError? err;
|
||||
var isJson = false;
|
||||
try {
|
||||
final parsed = jsonDecode(body);
|
||||
isJson = true;
|
||||
if (parsed is Map<String, dynamic> && parsed['error'] is Map<String, dynamic>) {
|
||||
err = ApiError.fromJson(parsed['error'] as Map<String, dynamic>);
|
||||
}
|
||||
} on FormatException {
|
||||
// not JSON
|
||||
}
|
||||
var message = err?.message ?? '';
|
||||
if (message.isEmpty) {
|
||||
var text = isJson ? '' : body.trim();
|
||||
if (text.length > 200) text = text.substring(0, 200);
|
||||
message = text.isNotEmpty ? text : '${resp.reasonPhrase ?? 'Error'} (${resp.statusCode})';
|
||||
}
|
||||
return ResolveSpecException(message, resp.statusCode, err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'client.dart';
|
||||
import 'types.dart';
|
||||
|
||||
/// Options sent to funcspec endpoints as X-* headers.
|
||||
///
|
||||
/// Server behaviour (pkg/funcspec): [sort] is inserted raw into ORDER BY (so it is sent as SQL
|
||||
/// terms); only one search operator per column is kept; values starting with `ZIP_` or `__`
|
||||
/// are base64-decoded by the server, so such plaintext values cannot be sent faithfully.
|
||||
class FuncSpecOptions {
|
||||
/// eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr.
|
||||
final List<FilterOption>? filters;
|
||||
|
||||
/// X-SearchFilter-{col}: text ILIKE.
|
||||
final Map<String, String>? searchFilters;
|
||||
final String? customSqlWhere;
|
||||
final String? customSqlOr;
|
||||
final List<SortOption>? sort;
|
||||
final int? limit;
|
||||
final int? offset;
|
||||
final bool? distinct;
|
||||
final bool? skipCount;
|
||||
final bool? skipCache;
|
||||
|
||||
/// simple | detail | syncfusion
|
||||
final String? responseFormat;
|
||||
|
||||
const FuncSpecOptions({
|
||||
this.filters,
|
||||
this.searchFilters,
|
||||
this.customSqlWhere,
|
||||
this.customSqlOr,
|
||||
this.sort,
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.distinct,
|
||||
this.skipCount,
|
||||
this.skipCache,
|
||||
this.responseFormat,
|
||||
});
|
||||
}
|
||||
|
||||
const _operatorMap = {
|
||||
'eq': 'equals',
|
||||
'neq': 'notequals',
|
||||
'gt': 'greaterthan',
|
||||
'gte': 'greaterthanorequal',
|
||||
'lt': 'lessthan',
|
||||
'lte': 'lessthanorequal',
|
||||
'like': 'contains',
|
||||
'ilike': 'contains',
|
||||
'contains': 'contains',
|
||||
'startswith': 'beginswith',
|
||||
'endswith': 'endswith',
|
||||
'in': 'in',
|
||||
'between': 'between',
|
||||
'between_inclusive': 'betweeninclusive',
|
||||
'is_null': 'empty',
|
||||
'is_not_null': 'notempty',
|
||||
};
|
||||
|
||||
String _scalar(Object? v) => v == null ? '' : v.toString();
|
||||
|
||||
String _filterValue(Object? v) => v is Iterable ? v.map(_scalar).join(',') : _scalar(v);
|
||||
|
||||
/// Base64 (UTF-8) with the `ZIP_` prefix.
|
||||
String encodeHeaderValue(String v) => 'ZIP_${base64.encode(utf8.encode(v))}';
|
||||
|
||||
/// Decode a value that may carry a `ZIP_` or `__` prefix (nested allowed).
|
||||
String decodeHeaderValue(String v) {
|
||||
for (final p in const ['ZIP_', '__']) {
|
||||
if (v.startsWith(p)) {
|
||||
var b64 = v.substring(p.length).replaceAll(RegExp(r'[\n\r ]'), '');
|
||||
b64 = b64.padRight(b64.length + (4 - b64.length % 4) % 4, '=');
|
||||
try {
|
||||
return decodeHeaderValue(utf8.decode(base64.decode(b64)));
|
||||
} on FormatException {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/// Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).
|
||||
String _safe(String v) {
|
||||
final unsafe = v != v.trim() || v.runes.any((c) => c > 127 || c < 32 || c == 127);
|
||||
return unsafe ? encodeHeaderValue(v) : v;
|
||||
}
|
||||
|
||||
/// Build the X-* headers understood by funcspec.ParseParameters.
|
||||
Map<String, String> buildHeaders(FuncSpecOptions? o) {
|
||||
final h = <String, String>{};
|
||||
if (o == null) return h;
|
||||
|
||||
for (final f in o.filters ?? const <FilterOption>[]) {
|
||||
final logic = f.logicOperator ?? 'AND';
|
||||
final v = _safe(_filterValue(f.value));
|
||||
if (f.operator == 'eq' && logic == 'AND') {
|
||||
h['X-FieldFilter-${f.column}'] = v;
|
||||
} else {
|
||||
final kind = logic == 'OR' ? 'X-SearchOr' : 'X-SearchOp';
|
||||
h['$kind-${_operatorMap[f.operator] ?? f.operator}-${f.column}'] = v;
|
||||
}
|
||||
}
|
||||
o.searchFilters?.forEach((col, text) => h['X-SearchFilter-$col'] = _safe(text));
|
||||
if (o.customSqlWhere != null && o.customSqlWhere!.isNotEmpty) h['X-Custom-SQL-W'] = _safe(o.customSqlWhere!);
|
||||
if (o.customSqlOr != null && o.customSqlOr!.isNotEmpty) h['X-Custom-SQL-Or'] = _safe(o.customSqlOr!);
|
||||
if (o.sort != null && o.sort!.isNotEmpty) {
|
||||
// funcspec puts this verbatim into ORDER BY
|
||||
h['X-Sort'] = _safe(o.sort!.map((s) => '${s.column} ${s.direction.toLowerCase() == 'desc' ? 'DESC' : 'ASC'}').join(','));
|
||||
}
|
||||
if (o.limit != null) h['X-Limit'] = '${o.limit}';
|
||||
if (o.offset != null) h['X-Offset'] = '${o.offset}';
|
||||
if (o.distinct != null) h['X-Distinct'] = '${o.distinct}';
|
||||
if (o.skipCount != null) h['X-SkipCount'] = '${o.skipCount}';
|
||||
if (o.skipCache != null) h['X-SkipCache'] = '${o.skipCache}';
|
||||
switch (o.responseFormat) {
|
||||
case 'simple':
|
||||
h['X-SimpleApi'] = 'true';
|
||||
case 'detail':
|
||||
h['X-DetailApi'] = 'true';
|
||||
case 'syncfusion':
|
||||
h['X-Syncfusion'] = 'true';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/// Build query-string pairs: lists -> repeated keys, null skipped, bools -> true/false.
|
||||
Map<String, List<String>> buildQuery(Map<String, Object?>? params) {
|
||||
final out = <String, List<String>>{};
|
||||
params?.forEach((k, v) {
|
||||
if (v == null) return;
|
||||
out[k] = v is Iterable ? v.map((e) => _safe(_scalar(e))).toList() : [_safe(_scalar(v))];
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
final _contentRange = RegExp(r'(\d+)-(\d+)/(\d+)');
|
||||
|
||||
Metadata _metadata(String? contentRange, FuncSpecOptions? o) {
|
||||
final m = _contentRange.firstMatch(contentRange ?? '');
|
||||
if (m == null) return Metadata(limit: o?.limit ?? 0);
|
||||
final start = int.parse(m.group(1)!);
|
||||
final end = int.parse(m.group(2)!);
|
||||
final total = int.parse(m.group(3)!);
|
||||
return Metadata(total: total, count: end - start, filtered: total, limit: o?.limit ?? 0, offset: start);
|
||||
}
|
||||
|
||||
/// Client for user-defined SQL endpoints. Routes are defined by the server application.
|
||||
class FuncSpecClient {
|
||||
final Transport _t;
|
||||
|
||||
FuncSpecClient(String baseUrl, [ClientOptions? options]) : _t = Transport(baseUrl, options);
|
||||
|
||||
void close() => _t.close();
|
||||
|
||||
Future<Response> _call(String method, String path, Map<String, Object?>? params, FuncSpecOptions? o, bool list) async {
|
||||
final base = Uri.parse('${_t.baseUrl}/${path.replaceAll(RegExp(r'^/+'), '')}');
|
||||
final pairs = <String>[];
|
||||
buildQuery(params).forEach((k, vs) {
|
||||
for (final v in vs) {
|
||||
pairs.add('${Uri.encodeQueryComponent(k)}=${Uri.encodeQueryComponent(v)}');
|
||||
}
|
||||
});
|
||||
final uri = pairs.isEmpty ? base : base.replace(query: pairs.join('&'));
|
||||
|
||||
final resp = await _t.send(method, uri, extra: buildHeaders(o));
|
||||
if (resp.statusCode < 200 || resp.statusCode > 299) throw Transport.errorFrom(resp); // 206 is success
|
||||
final text = utf8.decode(resp.bodyBytes);
|
||||
return Response(
|
||||
success: true,
|
||||
data: text.trim().isEmpty ? null : jsonDecode(text),
|
||||
metadata: list ? _metadata(resp.headers['content-range'], o) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Single-record endpoint (SqlQuery). `data` is the row object.
|
||||
Future<Response> query(String path, {Map<String, Object?>? params, FuncSpecOptions? options, String method = 'GET'}) =>
|
||||
_call(method.toUpperCase(), path, params, options, false);
|
||||
|
||||
/// List endpoint (SqlQueryList). Metadata comes from Content-Range.
|
||||
Future<Response> queryList(String path, {Map<String, Object?>? params, FuncSpecOptions? options, String method = 'GET'}) =>
|
||||
_call(method.toUpperCase(), path, params, options, true);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'client.dart';
|
||||
import 'types.dart';
|
||||
|
||||
/// Client for the ResolveSpec JSON body protocol: POST {operation, data, options}.
|
||||
///
|
||||
/// A record `id` of type `int` or `String` goes in the URL; a `List<String>` goes in the body.
|
||||
class ResolveSpecClient {
|
||||
final Transport _t;
|
||||
|
||||
ResolveSpecClient(String baseUrl, [ClientOptions? options]) : _t = Transport(baseUrl, options);
|
||||
|
||||
void close() => _t.close();
|
||||
|
||||
static String? _urlId(Object? id) => id == null || id is List ? null : id.toString();
|
||||
|
||||
static List<String>? _bodyId(Object? id) => id is List ? id.map((e) => e.toString()).toList() : null;
|
||||
|
||||
Uri _url(String schema, String entity, String? id) {
|
||||
var u = '${_t.baseUrl}/${Uri.encodeComponent(schema)}/${Uri.encodeComponent(entity)}';
|
||||
if (id != null && id.isNotEmpty) u += '/${Uri.encodeComponent(id)}';
|
||||
return Uri.parse(u);
|
||||
}
|
||||
|
||||
Future<Response> _send(String method, Uri url, Map<String, dynamic>? body) async {
|
||||
final resp = await _t.send(method, url, body: body == null ? null : jsonEncode(body));
|
||||
if (resp.statusCode < 200 || resp.statusCode > 299) throw Transport.errorFrom(resp);
|
||||
final decoded = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
final r = Response.fromJson(decoded as Map<String, dynamic>);
|
||||
if (!r.success && r.error != null) throw ResolveSpecException(r.error!.message, resp.statusCode, r.error);
|
||||
return r;
|
||||
}
|
||||
|
||||
/// GET /{schema}/{entity}
|
||||
Future<Response> getMetadata(String schema, String entity) => _send('GET', _url(schema, entity, null), null);
|
||||
|
||||
Future<Response> read(String schema, String entity, {Object? id, Options? options}) => _send(
|
||||
'POST',
|
||||
_url(schema, entity, _urlId(id)),
|
||||
{'operation': 'read', if (_bodyId(id) != null) 'id': _bodyId(id), if (options != null) 'options': options.toJson()},
|
||||
);
|
||||
|
||||
Future<Response> create(String schema, String entity, Object data, {Options? options}) => _send(
|
||||
'POST',
|
||||
_url(schema, entity, null),
|
||||
{'operation': 'create', 'data': data, if (options != null) 'options': options.toJson()},
|
||||
);
|
||||
|
||||
Future<Response> update(String schema, String entity, Object data, {Object? id, Options? options}) => _send(
|
||||
'POST',
|
||||
_url(schema, entity, _urlId(id)),
|
||||
{
|
||||
'operation': 'update',
|
||||
if (_bodyId(id) != null) 'id': _bodyId(id),
|
||||
'data': data,
|
||||
if (options != null) 'options': options.toJson(),
|
||||
},
|
||||
);
|
||||
|
||||
Future<Response> delete(String schema, String entity, Object id) =>
|
||||
_send('POST', _url(schema, entity, _urlId(id)), {'operation': 'delete'});
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Types aligned with Go pkg/common/types.go. toJson() emits the wire names.
|
||||
|
||||
Map<String, dynamic> _compact(Map<String, dynamic> m) {
|
||||
m.removeWhere((_, v) => v == null);
|
||||
return m;
|
||||
}
|
||||
|
||||
class FilterOption {
|
||||
final String column;
|
||||
|
||||
/// eq neq gt gte lt lte like ilike in contains startswith endswith between
|
||||
/// between_inclusive is_null is_not_null
|
||||
final String operator;
|
||||
final Object? value;
|
||||
|
||||
/// AND | OR
|
||||
final String? logicOperator;
|
||||
|
||||
const FilterOption(this.column, this.operator, [this.value, this.logicOperator]);
|
||||
|
||||
Map<String, dynamic> toJson() => _compact({
|
||||
'column': column,
|
||||
'operator': operator,
|
||||
'value': value,
|
||||
'logic_operator': logicOperator,
|
||||
});
|
||||
}
|
||||
|
||||
class SortOption {
|
||||
final String column;
|
||||
|
||||
/// asc | desc
|
||||
final String direction;
|
||||
|
||||
const SortOption(this.column, [this.direction = 'asc']);
|
||||
|
||||
Map<String, dynamic> toJson() => {'column': column, 'direction': direction};
|
||||
}
|
||||
|
||||
class Parameter {
|
||||
final String name;
|
||||
final String value;
|
||||
final int? sequence;
|
||||
|
||||
const Parameter(this.name, this.value, [this.sequence]);
|
||||
|
||||
Map<String, dynamic> toJson() => _compact({'name': name, 'value': value, 'sequence': sequence});
|
||||
}
|
||||
|
||||
class CustomOperator {
|
||||
final String name;
|
||||
final String sql;
|
||||
|
||||
const CustomOperator(this.name, this.sql);
|
||||
|
||||
Map<String, dynamic> toJson() => {'name': name, 'sql': sql};
|
||||
}
|
||||
|
||||
class ComputedColumn {
|
||||
final String name;
|
||||
final String expression;
|
||||
|
||||
const ComputedColumn(this.name, this.expression);
|
||||
|
||||
Map<String, dynamic> toJson() => {'name': name, 'expression': expression};
|
||||
}
|
||||
|
||||
class PreloadOption {
|
||||
final String? relation;
|
||||
final String? tableName;
|
||||
final List<String>? columns;
|
||||
final List<String>? omitColumns;
|
||||
final List<SortOption>? sort;
|
||||
final List<FilterOption>? filters;
|
||||
final String? where;
|
||||
final int? limit;
|
||||
final int? offset;
|
||||
final bool? updateable;
|
||||
final Map<String, String>? computedQl;
|
||||
final bool? recursive;
|
||||
final String? primaryKey;
|
||||
final String? relatedKey;
|
||||
final String? foreignKey;
|
||||
final String? recursiveChildKey;
|
||||
final List<String>? sqlJoins;
|
||||
final List<String>? joinAliases;
|
||||
|
||||
const PreloadOption({
|
||||
this.relation,
|
||||
this.tableName,
|
||||
this.columns,
|
||||
this.omitColumns,
|
||||
this.sort,
|
||||
this.filters,
|
||||
this.where,
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.updateable,
|
||||
this.computedQl,
|
||||
this.recursive,
|
||||
this.primaryKey,
|
||||
this.relatedKey,
|
||||
this.foreignKey,
|
||||
this.recursiveChildKey,
|
||||
this.sqlJoins,
|
||||
this.joinAliases,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => _compact({
|
||||
'relation': relation,
|
||||
'table_name': tableName,
|
||||
'columns': columns,
|
||||
'omit_columns': omitColumns,
|
||||
'sort': sort?.map((e) => e.toJson()).toList(),
|
||||
'filters': filters?.map((e) => e.toJson()).toList(),
|
||||
'where': where,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'updateable': updateable,
|
||||
'computed_ql': computedQl,
|
||||
'recursive': recursive,
|
||||
'primary_key': primaryKey,
|
||||
'related_key': relatedKey,
|
||||
'foreign_key': foreignKey,
|
||||
'recursive_child_key': recursiveChildKey,
|
||||
'sql_joins': sqlJoins,
|
||||
'join_aliases': joinAliases,
|
||||
});
|
||||
}
|
||||
|
||||
class VectorSearchOption {
|
||||
final String column;
|
||||
final List<double> vector;
|
||||
|
||||
/// l2 (default) | cosine | ip
|
||||
final String? metric;
|
||||
|
||||
/// Distance column alias, default _distance.
|
||||
final String? as;
|
||||
final String? direction;
|
||||
|
||||
const VectorSearchOption(this.column, this.vector, {this.metric, this.as, this.direction});
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
_compact({'column': column, 'vector': vector, 'metric': metric, 'as': as, 'direction': direction});
|
||||
}
|
||||
|
||||
/// ResolveSpec request options object.
|
||||
class Options {
|
||||
final List<PreloadOption>? preload;
|
||||
final List<String>? columns;
|
||||
final List<String>? omitColumns;
|
||||
final List<FilterOption>? filters;
|
||||
final List<SortOption>? sort;
|
||||
final int? limit;
|
||||
final int? offset;
|
||||
final List<CustomOperator>? customOperators;
|
||||
final List<ComputedColumn>? computedColumns;
|
||||
final List<Parameter>? parameters;
|
||||
final String? cursorForward;
|
||||
final String? cursorBackward;
|
||||
final String? fetchRowNumber;
|
||||
final VectorSearchOption? vectorSearch;
|
||||
|
||||
const Options({
|
||||
this.preload,
|
||||
this.columns,
|
||||
this.omitColumns,
|
||||
this.filters,
|
||||
this.sort,
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.customOperators,
|
||||
this.computedColumns,
|
||||
this.parameters,
|
||||
this.cursorForward,
|
||||
this.cursorBackward,
|
||||
this.fetchRowNumber,
|
||||
this.vectorSearch,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => _compact({
|
||||
'preload': preload?.map((e) => e.toJson()).toList(),
|
||||
'columns': columns,
|
||||
'omit_columns': omitColumns,
|
||||
'filters': filters?.map((e) => e.toJson()).toList(),
|
||||
'sort': sort?.map((e) => e.toJson()).toList(),
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'customOperators': customOperators?.map((e) => e.toJson()).toList(),
|
||||
'computedColumns': computedColumns?.map((e) => e.toJson()).toList(),
|
||||
'parameters': parameters?.map((e) => e.toJson()).toList(),
|
||||
'cursor_forward': cursorForward,
|
||||
'cursor_backward': cursorBackward,
|
||||
'fetch_row_number': fetchRowNumber,
|
||||
'vector_search': vectorSearch?.toJson(),
|
||||
});
|
||||
}
|
||||
|
||||
class Metadata {
|
||||
final int total;
|
||||
final int count;
|
||||
final int filtered;
|
||||
final int limit;
|
||||
final int offset;
|
||||
|
||||
const Metadata({this.total = 0, this.count = 0, this.filtered = 0, this.limit = 0, this.offset = 0});
|
||||
|
||||
factory Metadata.fromJson(Map<String, dynamic> j) => Metadata(
|
||||
total: (j['total'] as num?)?.toInt() ?? 0,
|
||||
count: (j['count'] as num?)?.toInt() ?? 0,
|
||||
filtered: (j['filtered'] as num?)?.toInt() ?? 0,
|
||||
limit: (j['limit'] as num?)?.toInt() ?? 0,
|
||||
offset: (j['offset'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Metadata &&
|
||||
other.total == total &&
|
||||
other.count == count &&
|
||||
other.filtered == filtered &&
|
||||
other.limit == limit &&
|
||||
other.offset == offset;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(total, count, filtered, limit, offset);
|
||||
|
||||
@override
|
||||
String toString() => 'Metadata(total: $total, count: $count, filtered: $filtered, limit: $limit, offset: $offset)';
|
||||
}
|
||||
|
||||
class ApiError {
|
||||
final String code;
|
||||
final String message;
|
||||
final Object? details;
|
||||
|
||||
/// Server-side reason (funcspec / restheadspec).
|
||||
final String? detail;
|
||||
final String? sql;
|
||||
|
||||
const ApiError({this.code = '', this.message = '', this.details, this.detail, this.sql});
|
||||
|
||||
factory ApiError.fromJson(Map<String, dynamic> j) => ApiError(
|
||||
code: (j['code'] as String?) ?? '',
|
||||
message: (j['message'] as String?) ?? '',
|
||||
details: j['details'],
|
||||
detail: j['detail'] as String?,
|
||||
sql: j['sql'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// ResolveSpec envelope. [data] is the decoded JSON value (Map, List or scalar).
|
||||
class Response {
|
||||
final bool success;
|
||||
final Object? data;
|
||||
final Metadata? metadata;
|
||||
final ApiError? error;
|
||||
|
||||
const Response({required this.success, this.data, this.metadata, this.error});
|
||||
|
||||
factory Response.fromJson(Map<String, dynamic> j) => Response(
|
||||
success: j['success'] == true,
|
||||
data: j['data'],
|
||||
metadata: j['metadata'] is Map<String, dynamic> ? Metadata.fromJson(j['metadata'] as Map<String, dynamic>) : null,
|
||||
error: j['error'] is Map<String, dynamic> ? ApiError.fromJson(j['error'] as Map<String, dynamic>) : null,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
name: resolvespec
|
||||
description: Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
http: ^1.2.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^4.0.0
|
||||
test: ^1.25.0
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:resolvespec/resolvespec.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
(http.Client, List<http.Request>) stub(int status, Object body, {Map<String, String> headers = const {}}) {
|
||||
final seen = <http.Request>[];
|
||||
final client = MockClient((req) async {
|
||||
seen.add(req);
|
||||
final text = body is String ? body : jsonEncode(body);
|
||||
return http.Response(text, status, headers: {'content-type': 'application/json', ...headers});
|
||||
});
|
||||
return (client, seen);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('resolvespec', () {
|
||||
test('read posts body with headers', () async {
|
||||
final (c, seen) = stub(200, {'success': true, 'data': [{'id': 1}]});
|
||||
final client = ResolveSpecClient(
|
||||
'http://localhost:3000/',
|
||||
ClientOptions(token: 'tok', headers: {'X-Tenant': 'a'}, httpClient: c),
|
||||
);
|
||||
final r = await client.read('public', 'users',
|
||||
options: const Options(limit: 5, filters: [FilterOption('a', 'eq', 1)]));
|
||||
final req = seen.single;
|
||||
expect(req.method, 'POST');
|
||||
expect(req.url.path, '/public/users');
|
||||
expect(req.headers['authorization'], 'Bearer tok');
|
||||
expect(req.headers['x-tenant'], 'a');
|
||||
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
expect(body['operation'], 'read');
|
||||
expect(body['options']['limit'], 5);
|
||||
expect(body.containsKey('id'), isFalse);
|
||||
expect((r.data as List).length, 1);
|
||||
});
|
||||
|
||||
test('id placement', () async {
|
||||
final (c, seen) = stub(200, {'success': true, 'data': {}});
|
||||
final client = ResolveSpecClient('http://x', ClientOptions(httpClient: c));
|
||||
await client.read('s', 'e', id: 7);
|
||||
expect(seen.last.url.path, '/s/e/7');
|
||||
await client.update('s', 'e', {'a': 1}, id: ['1', '2']);
|
||||
expect(seen.last.url.path, '/s/e');
|
||||
final b = jsonDecode(seen.last.body) as Map<String, dynamic>;
|
||||
expect(b['id'], ['1', '2']);
|
||||
expect(b['operation'], 'update');
|
||||
await client.delete('s', 'e', 'a/b');
|
||||
expect(seen.last.url.toString(), 'http://x/s/e/a%2Fb');
|
||||
expect(jsonDecode(seen.last.body)['operation'], 'delete');
|
||||
});
|
||||
|
||||
test('errors', () async {
|
||||
final client = ResolveSpecClient(
|
||||
'http://x',
|
||||
ClientOptions(
|
||||
httpClient: stub(400, {
|
||||
'success': false,
|
||||
'error': {'code': 'x', 'message': 'bad', 'detail': 'why'}
|
||||
}).$1),
|
||||
);
|
||||
await expectLater(
|
||||
client.read('s', 'e'),
|
||||
throwsA(isA<ResolveSpecException>()
|
||||
.having((e) => e.statusCode, 'status', 400)
|
||||
.having((e) => e.error.code, 'code', 'x')
|
||||
.having((e) => e.message, 'message', 'bad')
|
||||
.having((e) => e.error.detail, 'detail', 'why')),
|
||||
);
|
||||
final plain = ResolveSpecClient('http://x', ClientOptions(httpClient: stub(502, 'bad gateway').$1));
|
||||
await expectLater(
|
||||
plain.read('s', 'e'),
|
||||
throwsA(isA<ResolveSpecException>().having((e) => e.message, 'message', 'bad gateway')),
|
||||
);
|
||||
final soft = ResolveSpecClient(
|
||||
'http://x',
|
||||
ClientOptions(
|
||||
httpClient: stub(200, {
|
||||
'success': false,
|
||||
'error': {'code': 'c', 'message': 'nope'}
|
||||
}).$1),
|
||||
);
|
||||
await expectLater(soft.read('s', 'e'), throwsA(isA<ResolveSpecException>().having((e) => e.message, 'message', 'nope')));
|
||||
});
|
||||
});
|
||||
|
||||
group('funcspec', () {
|
||||
test('header filters', () {
|
||||
final h = buildHeaders(const FuncSpecOptions(filters: [
|
||||
FilterOption('status', 'eq', 'active'),
|
||||
FilterOption('age', 'gte', 18),
|
||||
FilterOption('name', 'contains', 'x', 'OR'),
|
||||
FilterOption('deleted', 'is_null'),
|
||||
FilterOption('id', 'in', [1, 2]),
|
||||
FilterOption('p', 'between_inclusive', [1, 5]),
|
||||
]));
|
||||
expect(h, {
|
||||
'X-FieldFilter-status': 'active',
|
||||
'X-SearchOp-greaterthanorequal-age': '18',
|
||||
'X-SearchOr-contains-name': 'x',
|
||||
'X-SearchOp-empty-deleted': '',
|
||||
'X-SearchOp-in-id': '1,2',
|
||||
'X-SearchOp-betweeninclusive-p': '1,5',
|
||||
});
|
||||
});
|
||||
|
||||
test('misc headers and encoding', () {
|
||||
var h = buildHeaders(const FuncSpecOptions(
|
||||
searchFilters: {'name': 'bob'},
|
||||
customSqlWhere: 'a = 1',
|
||||
customSqlOr: 'b = 2',
|
||||
sort: [SortOption('name', 'asc'), SortOption('created_at', 'DESC')],
|
||||
limit: 5,
|
||||
offset: 10,
|
||||
distinct: true,
|
||||
skipCount: true,
|
||||
skipCache: false,
|
||||
responseFormat: 'syncfusion',
|
||||
));
|
||||
expect(h['X-Sort'], 'name ASC,created_at DESC');
|
||||
expect(h['X-SearchFilter-name'], 'bob');
|
||||
expect(h['X-Custom-SQL-W'], 'a = 1');
|
||||
expect(h['X-SkipCache'], 'false');
|
||||
expect(h['X-Syncfusion'], 'true');
|
||||
|
||||
h = buildHeaders(const FuncSpecOptions(filters: [
|
||||
FilterOption('n', 'eq', 'héllo'),
|
||||
FilterOption('m', 'eq', ' pad'),
|
||||
]));
|
||||
expect(h['X-FieldFilter-n'], startsWith('ZIP_'));
|
||||
expect(decodeHeaderValue(h['X-FieldFilter-n']!), 'héllo');
|
||||
expect(decodeHeaderValue(h['X-FieldFilter-m']!), ' pad');
|
||||
});
|
||||
|
||||
test('query building', () {
|
||||
expect(buildQuery({'a': true, 'b': ['x', 'y'], 'c': null, 'd': 3}), {
|
||||
'a': ['true'],
|
||||
'b': ['x', 'y'],
|
||||
'd': ['3'],
|
||||
});
|
||||
});
|
||||
|
||||
test('queryList metadata', () async {
|
||||
final (c, seen) = stub(206, [{'id': 1}, {'id': 2}], headers: {'Content-Range': 'items 10-12/50'});
|
||||
final client = FuncSpecClient('http://x', ClientOptions(token: 'tok', httpClient: c));
|
||||
final r = await client.queryList('/api/users', params: {'org': 1}, options: const FuncSpecOptions(limit: 2));
|
||||
expect(seen.single.method, 'GET');
|
||||
expect(seen.single.url.path, '/api/users');
|
||||
expect(seen.single.url.query, 'org=1');
|
||||
expect(seen.single.headers['x-limit'], '2');
|
||||
expect(r.metadata, const Metadata(total: 50, count: 2, filtered: 50, limit: 2, offset: 10));
|
||||
expect((r.data as List).length, 2);
|
||||
});
|
||||
|
||||
test('query single and error', () async {
|
||||
final ok = FuncSpecClient('http://x', ClientOptions(httpClient: stub(200, {'id': 1}).$1));
|
||||
final r = await ok.query('api/u');
|
||||
expect(r.metadata, isNull);
|
||||
expect((r.data as Map)['id'], 1);
|
||||
|
||||
final bad = FuncSpecClient(
|
||||
'http://x',
|
||||
ClientOptions(
|
||||
httpClient: stub(400, {
|
||||
'success': false,
|
||||
'error': {'code': 'hook_error', 'message': 'Hook execution failed', 'detail': 'authentication required'}
|
||||
}).$1),
|
||||
);
|
||||
await expectLater(
|
||||
bad.query('api/u'),
|
||||
throwsA(isA<ResolveSpecException>()
|
||||
.having((e) => e.error.code, 'code', 'hook_error')
|
||||
.having((e) => e.error.detail, 'detail', 'authentication required')),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user