fix(clients): verify Dart client, case-insensitive Content-Range lookup

This commit is contained in:
2026-09-30 22:42:55 +02:00
parent ce706bacda
commit 17bb6ea76d
7 changed files with 200 additions and 70 deletions
+1 -1
View File
@@ -7,6 +7,6 @@
| `resolvespec-go` | Go | ResolveSpec, FunctionSpec | yes (`go test`) | | `resolvespec-go` | Go | ResolveSpec, FunctionSpec | yes (`go test`) |
| `resolvespec-rs` | Rust | ResolveSpec, FunctionSpec | yes (`cargo test`) | | `resolvespec-rs` | Rust | ResolveSpec, FunctionSpec | yes (`cargo test`) |
| `resolvespec-cs` | C# (.NET 8) | ResolveSpec, FunctionSpec | **not compiled** | | `resolvespec-cs` | C# (.NET 8) | ResolveSpec, FunctionSpec | **not compiled** |
| `resolvespec-dart` | Dart / Flutter | ResolveSpec, FunctionSpec | **not compiled** | | `resolvespec-dart` | Dart / Flutter | ResolveSpec, FunctionSpec | yes (`dart test`) |
Wire behaviour is identical across clients; FunctionSpec server quirks are listed in each README. Wire behaviour is identical across clients; FunctionSpec server quirks are listed in each README.
-2
View File
@@ -2,8 +2,6 @@
Dart / Flutter client for ResolveSpec (JSON body) and FunctionSpec. Depends on `package:http`. Dart >= 3.3. 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 ## Clients
| Type | Constructor | Methods | | Type | Constructor | Methods |
+21 -7
View File
@@ -10,7 +10,8 @@ class ResolveSpecException implements Exception {
final String message; final String message;
final ApiError error; final ApiError error;
ResolveSpecException(this.message, this.statusCode, [ApiError? error]) : error = error ?? ApiError(message: message); ResolveSpecException(this.message, this.statusCode, [ApiError? error])
: error = error ?? ApiError(message: message);
@override @override
String toString() => 'ResolveSpecException($statusCode): $message'; String toString() => 'ResolveSpecException($statusCode): $message';
@@ -25,7 +26,11 @@ class ClientOptions {
/// Supply your own client (tests, pooling). /// Supply your own client (tests, pooling).
final http.Client? httpClient; final http.Client? httpClient;
const ClientOptions({this.token, this.headers = const {}, this.timeout = const Duration(seconds: 30), this.httpClient}); const ClientOptions(
{this.token,
this.headers = const {},
this.timeout = const Duration(seconds: 30),
this.httpClient});
} }
class Transport { class Transport {
@@ -39,7 +44,8 @@ class Transport {
_http = options?.httpClient ?? http.Client(); _http = options?.httpClient ?? http.Client();
/// Content-Type < custom headers < per-call headers < bearer token. /// Content-Type < custom headers < per-call headers < bearer token.
Future<http.Response> send(String method, Uri uri, {String? body, Map<String, String>? extra}) { Future<http.Response> send(String method, Uri uri,
{String? body, Map<String, String>? extra}) {
final headers = <String, String>{'Content-Type': 'application/json'}; final headers = <String, String>{'Content-Type': 'application/json'};
void merge(Map<String, String> src) { void merge(Map<String, String> src) {
for (final e in src.entries) { for (final e in src.entries) {
@@ -51,11 +57,16 @@ class Transport {
merge(options.headers); merge(options.headers);
if (extra != null) merge(extra); if (extra != null) merge(extra);
final token = options.token; final token = options.token;
if (token != null && token.isNotEmpty) merge({'Authorization': 'Bearer $token'}); if (token != null && token.isNotEmpty) {
merge({'Authorization': 'Bearer $token'});
}
final req = http.Request(method, uri)..headers.addAll(headers); final req = http.Request(method, uri)..headers.addAll(headers);
if (body != null) req.body = body; if (body != null) req.body = body;
return _http.send(req).timeout(options.timeout).then(http.Response.fromStream); return _http
.send(req)
.timeout(options.timeout)
.then(http.Response.fromStream);
} }
void close() => _http.close(); void close() => _http.close();
@@ -67,7 +78,8 @@ class Transport {
try { try {
final parsed = jsonDecode(body); final parsed = jsonDecode(body);
isJson = true; isJson = true;
if (parsed is Map<String, dynamic> && parsed['error'] is Map<String, dynamic>) { if (parsed is Map<String, dynamic> &&
parsed['error'] is Map<String, dynamic>) {
err = ApiError.fromJson(parsed['error'] as Map<String, dynamic>); err = ApiError.fromJson(parsed['error'] as Map<String, dynamic>);
} }
} on FormatException { } on FormatException {
@@ -77,7 +89,9 @@ class Transport {
if (message.isEmpty) { if (message.isEmpty) {
var text = isJson ? '' : body.trim(); var text = isJson ? '' : body.trim();
if (text.length > 200) text = text.substring(0, 200); if (text.length > 200) text = text.substring(0, 200);
message = text.isNotEmpty ? text : '${resp.reasonPhrase ?? 'Error'} (${resp.statusCode})'; message = text.isNotEmpty
? text
: '${resp.reasonPhrase ?? 'Error'} (${resp.statusCode})';
} }
return ResolveSpecException(message, resp.statusCode, err); return ResolveSpecException(message, resp.statusCode, err);
} }
+53 -16
View File
@@ -62,7 +62,8 @@ const _operatorMap = {
String _scalar(Object? v) => v == null ? '' : v.toString(); String _scalar(Object? v) => v == null ? '' : v.toString();
String _filterValue(Object? v) => v is Iterable ? v.map(_scalar).join(',') : _scalar(v); String _filterValue(Object? v) =>
v is Iterable ? v.map(_scalar).join(',') : _scalar(v);
/// Base64 (UTF-8) with the `ZIP_` prefix. /// Base64 (UTF-8) with the `ZIP_` prefix.
String encodeHeaderValue(String v) => 'ZIP_${base64.encode(utf8.encode(v))}'; String encodeHeaderValue(String v) => 'ZIP_${base64.encode(utf8.encode(v))}';
@@ -85,7 +86,8 @@ String decodeHeaderValue(String v) {
/// Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces). /// Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).
String _safe(String v) { String _safe(String v) {
final unsafe = v != v.trim() || v.runes.any((c) => c > 127 || c < 32 || c == 127); final unsafe =
v != v.trim() || v.runes.any((c) => c > 127 || c < 32 || c == 127);
return unsafe ? encodeHeaderValue(v) : v; return unsafe ? encodeHeaderValue(v) : v;
} }
@@ -104,12 +106,20 @@ Map<String, String> buildHeaders(FuncSpecOptions? o) {
h['$kind-${_operatorMap[f.operator] ?? f.operator}-${f.column}'] = v; h['$kind-${_operatorMap[f.operator] ?? f.operator}-${f.column}'] = v;
} }
} }
o.searchFilters?.forEach((col, text) => h['X-SearchFilter-$col'] = _safe(text)); o.searchFilters
if (o.customSqlWhere != null && o.customSqlWhere!.isNotEmpty) h['X-Custom-SQL-W'] = _safe(o.customSqlWhere!); ?.forEach((col, text) => h['X-SearchFilter-$col'] = _safe(text));
if (o.customSqlOr != null && o.customSqlOr!.isNotEmpty) h['X-Custom-SQL-Or'] = _safe(o.customSqlOr!); 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) { if (o.sort != null && o.sort!.isNotEmpty) {
// funcspec puts this verbatim into ORDER BY // funcspec puts this verbatim into ORDER BY
h['X-Sort'] = _safe(o.sort!.map((s) => '${s.column} ${s.direction.toLowerCase() == 'desc' ? 'DESC' : 'ASC'}').join(',')); 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.limit != null) h['X-Limit'] = '${o.limit}';
if (o.offset != null) h['X-Offset'] = '${o.offset}'; if (o.offset != null) h['X-Offset'] = '${o.offset}';
@@ -132,11 +142,20 @@ Map<String, List<String>> buildQuery(Map<String, Object?>? params) {
final out = <String, List<String>>{}; final out = <String, List<String>>{};
params?.forEach((k, v) { params?.forEach((k, v) {
if (v == null) return; if (v == null) return;
out[k] = v is Iterable ? v.map((e) => _safe(_scalar(e))).toList() : [_safe(_scalar(v))]; out[k] = v is Iterable
? v.map((e) => _safe(_scalar(e))).toList()
: [_safe(_scalar(v))];
}); });
return out; return out;
} }
String? _header(Map<String, String> headers, String name) {
for (final e in headers.entries) {
if (e.key.toLowerCase() == name) return e.value;
}
return null;
}
final _contentRange = RegExp(r'(\d+)-(\d+)/(\d+)'); final _contentRange = RegExp(r'(\d+)-(\d+)/(\d+)');
Metadata _metadata(String? contentRange, FuncSpecOptions? o) { Metadata _metadata(String? contentRange, FuncSpecOptions? o) {
@@ -145,42 +164,60 @@ Metadata _metadata(String? contentRange, FuncSpecOptions? o) {
final start = int.parse(m.group(1)!); final start = int.parse(m.group(1)!);
final end = int.parse(m.group(2)!); final end = int.parse(m.group(2)!);
final total = int.parse(m.group(3)!); final total = int.parse(m.group(3)!);
return Metadata(total: total, count: end - start, filtered: total, limit: o?.limit ?? 0, offset: start); 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. /// Client for user-defined SQL endpoints. Routes are defined by the server application.
class FuncSpecClient { class FuncSpecClient {
final Transport _t; final Transport _t;
FuncSpecClient(String baseUrl, [ClientOptions? options]) : _t = Transport(baseUrl, options); FuncSpecClient(String baseUrl, [ClientOptions? options])
: _t = Transport(baseUrl, options);
void close() => _t.close(); void close() => _t.close();
Future<Response> _call(String method, String path, Map<String, Object?>? params, FuncSpecOptions? o, bool list) async { Future<Response> _call(String method, String path,
final base = Uri.parse('${_t.baseUrl}/${path.replaceAll(RegExp(r'^/+'), '')}'); Map<String, Object?>? params, FuncSpecOptions? o, bool list) async {
final base =
Uri.parse('${_t.baseUrl}/${path.replaceAll(RegExp(r'^/+'), '')}');
final pairs = <String>[]; final pairs = <String>[];
buildQuery(params).forEach((k, vs) { buildQuery(params).forEach((k, vs) {
for (final v in vs) { for (final v in vs) {
pairs.add('${Uri.encodeQueryComponent(k)}=${Uri.encodeQueryComponent(v)}'); pairs.add(
'${Uri.encodeQueryComponent(k)}=${Uri.encodeQueryComponent(v)}');
} }
}); });
final uri = pairs.isEmpty ? base : base.replace(query: pairs.join('&')); final uri = pairs.isEmpty ? base : base.replace(query: pairs.join('&'));
final resp = await _t.send(method, uri, extra: buildHeaders(o)); final resp = await _t.send(method, uri, extra: buildHeaders(o));
if (resp.statusCode < 200 || resp.statusCode > 299) throw Transport.errorFrom(resp); // 206 is success if (resp.statusCode < 200 || resp.statusCode > 299) {
throw Transport.errorFrom(resp); // 206 is success
}
final text = utf8.decode(resp.bodyBytes); final text = utf8.decode(resp.bodyBytes);
return Response( return Response(
success: true, success: true,
data: text.trim().isEmpty ? null : jsonDecode(text), data: text.trim().isEmpty ? null : jsonDecode(text),
metadata: list ? _metadata(resp.headers['content-range'], o) : null, metadata:
list ? _metadata(_header(resp.headers, 'content-range'), o) : null,
); );
} }
/// Single-record endpoint (SqlQuery). `data` is the row object. /// Single-record endpoint (SqlQuery). `data` is the row object.
Future<Response> query(String path, {Map<String, Object?>? params, FuncSpecOptions? options, String method = 'GET'}) => Future<Response> query(String path,
{Map<String, Object?>? params,
FuncSpecOptions? options,
String method = 'GET'}) =>
_call(method.toUpperCase(), path, params, options, false); _call(method.toUpperCase(), path, params, options, false);
/// List endpoint (SqlQueryList). Metadata comes from Content-Range. /// List endpoint (SqlQueryList). Metadata comes from Content-Range.
Future<Response> queryList(String path, {Map<String, Object?>? params, FuncSpecOptions? options, String method = 'GET'}) => Future<Response> queryList(String path,
{Map<String, Object?>? params,
FuncSpecOptions? options,
String method = 'GET'}) =>
_call(method.toUpperCase(), path, params, options, true); _call(method.toUpperCase(), path, params, options, true);
} }
@@ -9,45 +9,70 @@ import 'types.dart';
class ResolveSpecClient { class ResolveSpecClient {
final Transport _t; final Transport _t;
ResolveSpecClient(String baseUrl, [ClientOptions? options]) : _t = Transport(baseUrl, options); ResolveSpecClient(String baseUrl, [ClientOptions? options])
: _t = Transport(baseUrl, options);
void close() => _t.close(); void close() => _t.close();
static String? _urlId(Object? id) => id == null || id is List ? null : id.toString(); 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; static List<String>? _bodyId(Object? id) =>
id is List ? id.map((e) => e.toString()).toList() : null;
Uri _url(String schema, String entity, String? id) { Uri _url(String schema, String entity, String? id) {
var u = '${_t.baseUrl}/${Uri.encodeComponent(schema)}/${Uri.encodeComponent(entity)}'; var u =
'${_t.baseUrl}/${Uri.encodeComponent(schema)}/${Uri.encodeComponent(entity)}';
if (id != null && id.isNotEmpty) u += '/${Uri.encodeComponent(id)}'; if (id != null && id.isNotEmpty) u += '/${Uri.encodeComponent(id)}';
return Uri.parse(u); return Uri.parse(u);
} }
Future<Response> _send(String method, Uri url, Map<String, dynamic>? body) async { Future<Response> _send(
final resp = await _t.send(method, url, body: body == null ? null : jsonEncode(body)); String method, Uri url, Map<String, dynamic>? body) async {
if (resp.statusCode < 200 || resp.statusCode > 299) throw Transport.errorFrom(resp); 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 decoded = jsonDecode(utf8.decode(resp.bodyBytes));
final r = Response.fromJson(decoded as Map<String, dynamic>); final r = Response.fromJson(decoded as Map<String, dynamic>);
if (!r.success && r.error != null) throw ResolveSpecException(r.error!.message, resp.statusCode, r.error); if (!r.success && r.error != null) {
throw ResolveSpecException(r.error!.message, resp.statusCode, r.error);
}
return r; return r;
} }
/// GET /{schema}/{entity} /// GET /{schema}/{entity}
Future<Response> getMetadata(String schema, String entity) => _send('GET', _url(schema, entity, null), null); 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( Future<Response> read(String schema, String entity,
{Object? id, Options? options}) =>
_send(
'POST', 'POST',
_url(schema, entity, _urlId(id)), _url(schema, entity, _urlId(id)),
{'operation': 'read', if (_bodyId(id) != null) 'id': _bodyId(id), if (options != null) 'options': options.toJson()}, {
'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( Future<Response> create(String schema, String entity, Object data,
{Options? options}) =>
_send(
'POST', 'POST',
_url(schema, entity, null), _url(schema, entity, null),
{'operation': 'create', 'data': data, if (options != null) 'options': options.toJson()}, {
'operation': 'create',
'data': data,
if (options != null) 'options': options.toJson()
},
); );
Future<Response> update(String schema, String entity, Object data, {Object? id, Options? options}) => _send( Future<Response> update(String schema, String entity, Object data,
{Object? id, Options? options}) =>
_send(
'POST', 'POST',
_url(schema, entity, _urlId(id)), _url(schema, entity, _urlId(id)),
{ {
+29 -10
View File
@@ -16,7 +16,8 @@ class FilterOption {
/// AND | OR /// AND | OR
final String? logicOperator; final String? logicOperator;
const FilterOption(this.column, this.operator, [this.value, this.logicOperator]); const FilterOption(this.column, this.operator,
[this.value, this.logicOperator]);
Map<String, dynamic> toJson() => _compact({ Map<String, dynamic> toJson() => _compact({
'column': column, 'column': column,
@@ -44,7 +45,8 @@ class Parameter {
const Parameter(this.name, this.value, [this.sequence]); const Parameter(this.name, this.value, [this.sequence]);
Map<String, dynamic> toJson() => _compact({'name': name, 'value': value, 'sequence': sequence}); Map<String, dynamic> toJson() =>
_compact({'name': name, 'value': value, 'sequence': sequence});
} }
class CustomOperator { class CustomOperator {
@@ -139,10 +141,16 @@ class VectorSearchOption {
final String? as; final String? as;
final String? direction; final String? direction;
const VectorSearchOption(this.column, this.vector, {this.metric, this.as, this.direction}); const VectorSearchOption(this.column, this.vector,
{this.metric, this.as, this.direction});
Map<String, dynamic> toJson() => Map<String, dynamic> toJson() => _compact({
_compact({'column': column, 'vector': vector, 'metric': metric, 'as': as, 'direction': direction}); 'column': column,
'vector': vector,
'metric': metric,
'as': as,
'direction': direction
});
} }
/// ResolveSpec request options object. /// ResolveSpec request options object.
@@ -204,7 +212,12 @@ class Metadata {
final int limit; final int limit;
final int offset; final int offset;
const Metadata({this.total = 0, this.count = 0, this.filtered = 0, this.limit = 0, this.offset = 0}); 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( factory Metadata.fromJson(Map<String, dynamic> j) => Metadata(
total: (j['total'] as num?)?.toInt() ?? 0, total: (j['total'] as num?)?.toInt() ?? 0,
@@ -227,7 +240,8 @@ class Metadata {
int get hashCode => Object.hash(total, count, filtered, limit, offset); int get hashCode => Object.hash(total, count, filtered, limit, offset);
@override @override
String toString() => 'Metadata(total: $total, count: $count, filtered: $filtered, limit: $limit, offset: $offset)'; String toString() =>
'Metadata(total: $total, count: $count, filtered: $filtered, limit: $limit, offset: $offset)';
} }
class ApiError { class ApiError {
@@ -239,7 +253,8 @@ class ApiError {
final String? detail; final String? detail;
final String? sql; final String? sql;
const ApiError({this.code = '', this.message = '', this.details, this.detail, this.sql}); const ApiError(
{this.code = '', this.message = '', this.details, this.detail, this.sql});
factory ApiError.fromJson(Map<String, dynamic> j) => ApiError( factory ApiError.fromJson(Map<String, dynamic> j) => ApiError(
code: (j['code'] as String?) ?? '', code: (j['code'] as String?) ?? '',
@@ -262,7 +277,11 @@ class Response {
factory Response.fromJson(Map<String, dynamic> j) => Response( factory Response.fromJson(Map<String, dynamic> j) => Response(
success: j['success'] == true, success: j['success'] == true,
data: j['data'], data: j['data'],
metadata: j['metadata'] is Map<String, dynamic> ? Metadata.fromJson(j['metadata'] as Map<String, dynamic>) : null, metadata: j['metadata'] is Map<String, dynamic>
error: j['error'] is Map<String, dynamic> ? ApiError.fromJson(j['error'] as Map<String, dynamic>) : null, ? 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,
); );
} }
+57 -20
View File
@@ -5,12 +5,14 @@ import 'package:http/testing.dart';
import 'package:resolvespec/resolvespec.dart'; import 'package:resolvespec/resolvespec.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
(http.Client, List<http.Request>) stub(int status, Object body, {Map<String, String> headers = const {}}) { (http.Client, List<http.Request>) stub(int status, Object body,
{Map<String, String> headers = const {}}) {
final seen = <http.Request>[]; final seen = <http.Request>[];
final client = MockClient((req) async { final client = MockClient((req) async {
seen.add(req); seen.add(req);
final text = body is String ? body : jsonEncode(body); final text = body is String ? body : jsonEncode(body);
return http.Response(text, status, headers: {'content-type': 'application/json', ...headers}); return http.Response(text, status,
headers: {'content-type': 'application/json', ...headers});
}); });
return (client, seen); return (client, seen);
} }
@@ -18,13 +20,19 @@ import 'package:test/test.dart';
void main() { void main() {
group('resolvespec', () { group('resolvespec', () {
test('read posts body with headers', () async { test('read posts body with headers', () async {
final (c, seen) = stub(200, {'success': true, 'data': [{'id': 1}]}); final (c, seen) = stub(200, {
'success': true,
'data': [
{'id': 1}
]
});
final client = ResolveSpecClient( final client = ResolveSpecClient(
'http://localhost:3000/', 'http://localhost:3000/',
ClientOptions(token: 'tok', headers: {'X-Tenant': 'a'}, httpClient: c), ClientOptions(token: 'tok', headers: {'X-Tenant': 'a'}, httpClient: c),
); );
final r = await client.read('public', 'users', final r = await client.read('public', 'users',
options: const Options(limit: 5, filters: [FilterOption('a', 'eq', 1)])); options:
const Options(limit: 5, filters: [FilterOption('a', 'eq', 1)]));
final req = seen.single; final req = seen.single;
expect(req.method, 'POST'); expect(req.method, 'POST');
expect(req.url.path, '/public/users'); expect(req.url.path, '/public/users');
@@ -39,7 +47,8 @@ void main() {
test('id placement', () async { test('id placement', () async {
final (c, seen) = stub(200, {'success': true, 'data': {}}); final (c, seen) = stub(200, {'success': true, 'data': {}});
final client = ResolveSpecClient('http://x', ClientOptions(httpClient: c)); final client =
ResolveSpecClient('http://x', ClientOptions(httpClient: c));
await client.read('s', 'e', id: 7); await client.read('s', 'e', id: 7);
expect(seen.last.url.path, '/s/e/7'); expect(seen.last.url.path, '/s/e/7');
await client.update('s', 'e', {'a': 1}, id: ['1', '2']); await client.update('s', 'e', {'a': 1}, id: ['1', '2']);
@@ -69,10 +78,12 @@ void main() {
.having((e) => e.message, 'message', 'bad') .having((e) => e.message, 'message', 'bad')
.having((e) => e.error.detail, 'detail', 'why')), .having((e) => e.error.detail, 'detail', 'why')),
); );
final plain = ResolveSpecClient('http://x', ClientOptions(httpClient: stub(502, 'bad gateway').$1)); final plain = ResolveSpecClient(
'http://x', ClientOptions(httpClient: stub(502, 'bad gateway').$1));
await expectLater( await expectLater(
plain.read('s', 'e'), plain.read('s', 'e'),
throwsA(isA<ResolveSpecException>().having((e) => e.message, 'message', 'bad gateway')), throwsA(isA<ResolveSpecException>()
.having((e) => e.message, 'message', 'bad gateway')),
); );
final soft = ResolveSpecClient( final soft = ResolveSpecClient(
'http://x', 'http://x',
@@ -82,7 +93,10 @@ void main() {
'error': {'code': 'c', 'message': 'nope'} 'error': {'code': 'c', 'message': 'nope'}
}).$1), }).$1),
); );
await expectLater(soft.read('s', 'e'), throwsA(isA<ResolveSpecException>().having((e) => e.message, 'message', 'nope'))); await expectLater(
soft.read('s', 'e'),
throwsA(isA<ResolveSpecException>()
.having((e) => e.message, 'message', 'nope')));
}); });
}); });
@@ -135,27 +149,45 @@ void main() {
}); });
test('query building', () { test('query building', () {
expect(buildQuery({'a': true, 'b': ['x', 'y'], 'c': null, 'd': 3}), { expect(
'a': ['true'], buildQuery({
'b': ['x', 'y'], 'a': true,
'd': ['3'], 'b': ['x', 'y'],
}); 'c': null,
'd': 3
}),
{
'a': ['true'],
'b': ['x', 'y'],
'd': ['3'],
});
}); });
test('queryList metadata', () async { test('queryList metadata', () async {
final (c, seen) = stub(206, [{'id': 1}, {'id': 2}], headers: {'Content-Range': 'items 10-12/50'}); final (c, seen) = stub(206, [
final client = FuncSpecClient('http://x', ClientOptions(token: 'tok', httpClient: c)); {'id': 1},
final r = await client.queryList('/api/users', params: {'org': 1}, options: const FuncSpecOptions(limit: 2)); {'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.method, 'GET');
expect(seen.single.url.path, '/api/users'); expect(seen.single.url.path, '/api/users');
expect(seen.single.url.query, 'org=1'); expect(seen.single.url.query, 'org=1');
expect(seen.single.headers['x-limit'], '2'); expect(seen.single.headers['x-limit'], '2');
expect(r.metadata, const Metadata(total: 50, count: 2, filtered: 50, limit: 2, offset: 10)); expect(
r.metadata,
const Metadata(
total: 50, count: 2, filtered: 50, limit: 2, offset: 10));
expect((r.data as List).length, 2); expect((r.data as List).length, 2);
}); });
test('query single and error', () async { test('query single and error', () async {
final ok = FuncSpecClient('http://x', ClientOptions(httpClient: stub(200, {'id': 1}).$1)); final ok = FuncSpecClient(
'http://x', ClientOptions(httpClient: stub(200, {'id': 1}).$1));
final r = await ok.query('api/u'); final r = await ok.query('api/u');
expect(r.metadata, isNull); expect(r.metadata, isNull);
expect((r.data as Map)['id'], 1); expect((r.data as Map)['id'], 1);
@@ -165,14 +197,19 @@ void main() {
ClientOptions( ClientOptions(
httpClient: stub(400, { httpClient: stub(400, {
'success': false, 'success': false,
'error': {'code': 'hook_error', 'message': 'Hook execution failed', 'detail': 'authentication required'} 'error': {
'code': 'hook_error',
'message': 'Hook execution failed',
'detail': 'authentication required'
}
}).$1), }).$1),
); );
await expectLater( await expectLater(
bad.query('api/u'), bad.query('api/u'),
throwsA(isA<ResolveSpecException>() throwsA(isA<ResolveSpecException>()
.having((e) => e.error.code, 'code', 'hook_error') .having((e) => e.error.code, 'code', 'hook_error')
.having((e) => e.error.detail, 'detail', 'authentication required')), .having(
(e) => e.error.detail, 'detail', 'authentication required')),
); );
}); });
}); });