diff --git a/clients/README.md b/clients/README.md index 1039b0d..b6964d2 100644 --- a/clients/README.md +++ b/clients/README.md @@ -7,6 +7,6 @@ | `resolvespec-go` | Go | ResolveSpec, FunctionSpec | yes (`go test`) | | `resolvespec-rs` | Rust | ResolveSpec, FunctionSpec | yes (`cargo test`) | | `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. diff --git a/clients/resolvespec-dart/README.md b/clients/resolvespec-dart/README.md index 825f1af..3a00124 100644 --- a/clients/resolvespec-dart/README.md +++ b/clients/resolvespec-dart/README.md @@ -2,8 +2,6 @@ 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 | diff --git a/clients/resolvespec-dart/lib/src/client.dart b/clients/resolvespec-dart/lib/src/client.dart index 3c2baa8..6bf0367 100644 --- a/clients/resolvespec-dart/lib/src/client.dart +++ b/clients/resolvespec-dart/lib/src/client.dart @@ -10,7 +10,8 @@ class ResolveSpecException implements Exception { final String message; 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 String toString() => 'ResolveSpecException($statusCode): $message'; @@ -25,7 +26,11 @@ class ClientOptions { /// 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}); + const ClientOptions( + {this.token, + this.headers = const {}, + this.timeout = const Duration(seconds: 30), + this.httpClient}); } class Transport { @@ -39,7 +44,8 @@ class Transport { _http = options?.httpClient ?? http.Client(); /// Content-Type < custom headers < per-call headers < bearer token. - Future send(String method, Uri uri, {String? body, Map? extra}) { + Future send(String method, Uri uri, + {String? body, Map? extra}) { final headers = {'Content-Type': 'application/json'}; void merge(Map src) { for (final e in src.entries) { @@ -51,11 +57,16 @@ class Transport { merge(options.headers); if (extra != null) merge(extra); 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); 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(); @@ -67,7 +78,8 @@ class Transport { try { final parsed = jsonDecode(body); isJson = true; - if (parsed is Map && parsed['error'] is Map) { + if (parsed is Map && + parsed['error'] is Map) { err = ApiError.fromJson(parsed['error'] as Map); } } on FormatException { @@ -77,7 +89,9 @@ class Transport { 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})'; + message = text.isNotEmpty + ? text + : '${resp.reasonPhrase ?? 'Error'} (${resp.statusCode})'; } return ResolveSpecException(message, resp.statusCode, err); } diff --git a/clients/resolvespec-dart/lib/src/funcspec.dart b/clients/resolvespec-dart/lib/src/funcspec.dart index 49e9226..3868c5f 100644 --- a/clients/resolvespec-dart/lib/src/funcspec.dart +++ b/clients/resolvespec-dart/lib/src/funcspec.dart @@ -62,7 +62,8 @@ const _operatorMap = { 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. 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). 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; } @@ -104,12 +106,20 @@ Map buildHeaders(FuncSpecOptions? o) { 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!); + 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(',')); + 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}'; @@ -132,11 +142,20 @@ Map> buildQuery(Map? params) { final out = >{}; params?.forEach((k, v) { 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; } +String? _header(Map 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+)'); Metadata _metadata(String? contentRange, FuncSpecOptions? o) { @@ -145,42 +164,60 @@ Metadata _metadata(String? contentRange, FuncSpecOptions? o) { 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); + 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); + FuncSpecClient(String baseUrl, [ClientOptions? options]) + : _t = Transport(baseUrl, options); void close() => _t.close(); - Future _call(String method, String path, Map? params, FuncSpecOptions? o, bool list) async { - final base = Uri.parse('${_t.baseUrl}/${path.replaceAll(RegExp(r'^/+'), '')}'); + Future _call(String method, String path, + Map? params, FuncSpecOptions? o, bool list) async { + final base = + Uri.parse('${_t.baseUrl}/${path.replaceAll(RegExp(r'^/+'), '')}'); final pairs = []; buildQuery(params).forEach((k, 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 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); return Response( success: true, 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. - Future query(String path, {Map? params, FuncSpecOptions? options, String method = 'GET'}) => + Future query(String path, + {Map? params, + FuncSpecOptions? options, + String method = 'GET'}) => _call(method.toUpperCase(), path, params, options, false); /// List endpoint (SqlQueryList). Metadata comes from Content-Range. - Future queryList(String path, {Map? params, FuncSpecOptions? options, String method = 'GET'}) => + Future queryList(String path, + {Map? params, + FuncSpecOptions? options, + String method = 'GET'}) => _call(method.toUpperCase(), path, params, options, true); } diff --git a/clients/resolvespec-dart/lib/src/resolvespec.dart b/clients/resolvespec-dart/lib/src/resolvespec.dart index 1ec5cf8..b3cb73b 100644 --- a/clients/resolvespec-dart/lib/src/resolvespec.dart +++ b/clients/resolvespec-dart/lib/src/resolvespec.dart @@ -9,45 +9,70 @@ import 'types.dart'; class ResolveSpecClient { 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(); - 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? _bodyId(Object? id) => id is List ? id.map((e) => e.toString()).toList() : null; + static List? _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)}'; + var u = + '${_t.baseUrl}/${Uri.encodeComponent(schema)}/${Uri.encodeComponent(entity)}'; if (id != null && id.isNotEmpty) u += '/${Uri.encodeComponent(id)}'; return Uri.parse(u); } - Future _send(String method, Uri url, Map? 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); + Future _send( + String method, Uri url, Map? 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); - 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; } /// GET /{schema}/{entity} - Future getMetadata(String schema, String entity) => _send('GET', _url(schema, entity, null), null); + Future getMetadata(String schema, String entity) => + _send('GET', _url(schema, entity, null), null); - Future read(String schema, String entity, {Object? id, Options? options}) => _send( + Future 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()}, + { + 'operation': 'read', + if (_bodyId(id) != null) 'id': _bodyId(id), + if (options != null) 'options': options.toJson() + }, ); - Future create(String schema, String entity, Object data, {Options? options}) => _send( + Future 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()}, + { + 'operation': 'create', + 'data': data, + if (options != null) 'options': options.toJson() + }, ); - Future update(String schema, String entity, Object data, {Object? id, Options? options}) => _send( + Future update(String schema, String entity, Object data, + {Object? id, Options? options}) => + _send( 'POST', _url(schema, entity, _urlId(id)), { diff --git a/clients/resolvespec-dart/lib/src/types.dart b/clients/resolvespec-dart/lib/src/types.dart index f56d613..1aaac6f 100644 --- a/clients/resolvespec-dart/lib/src/types.dart +++ b/clients/resolvespec-dart/lib/src/types.dart @@ -16,7 +16,8 @@ class FilterOption { /// AND | OR final String? logicOperator; - const FilterOption(this.column, this.operator, [this.value, this.logicOperator]); + const FilterOption(this.column, this.operator, + [this.value, this.logicOperator]); Map toJson() => _compact({ 'column': column, @@ -44,7 +45,8 @@ class Parameter { const Parameter(this.name, this.value, [this.sequence]); - Map toJson() => _compact({'name': name, 'value': value, 'sequence': sequence}); + Map toJson() => + _compact({'name': name, 'value': value, 'sequence': sequence}); } class CustomOperator { @@ -139,10 +141,16 @@ class VectorSearchOption { final String? as; 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 toJson() => - _compact({'column': column, 'vector': vector, 'metric': metric, 'as': as, 'direction': direction}); + Map toJson() => _compact({ + 'column': column, + 'vector': vector, + 'metric': metric, + 'as': as, + 'direction': direction + }); } /// ResolveSpec request options object. @@ -204,7 +212,12 @@ class Metadata { final int limit; 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 j) => Metadata( total: (j['total'] as num?)?.toInt() ?? 0, @@ -227,7 +240,8 @@ class Metadata { int get hashCode => Object.hash(total, count, filtered, limit, offset); @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 { @@ -239,7 +253,8 @@ class ApiError { final String? detail; 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 j) => ApiError( code: (j['code'] as String?) ?? '', @@ -262,7 +277,11 @@ class Response { factory Response.fromJson(Map j) => Response( success: j['success'] == true, data: j['data'], - metadata: j['metadata'] is Map ? Metadata.fromJson(j['metadata'] as Map) : null, - error: j['error'] is Map ? ApiError.fromJson(j['error'] as Map) : null, + metadata: j['metadata'] is Map + ? Metadata.fromJson(j['metadata'] as Map) + : null, + error: j['error'] is Map + ? ApiError.fromJson(j['error'] as Map) + : null, ); } diff --git a/clients/resolvespec-dart/test/client_test.dart b/clients/resolvespec-dart/test/client_test.dart index 7e213a1..afe1310 100644 --- a/clients/resolvespec-dart/test/client_test.dart +++ b/clients/resolvespec-dart/test/client_test.dart @@ -5,12 +5,14 @@ import 'package:http/testing.dart'; import 'package:resolvespec/resolvespec.dart'; import 'package:test/test.dart'; -(http.Client, List) stub(int status, Object body, {Map headers = const {}}) { +(http.Client, List) stub(int status, Object body, + {Map headers = const {}}) { final seen = []; 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 http.Response(text, status, + headers: {'content-type': 'application/json', ...headers}); }); return (client, seen); } @@ -18,13 +20,19 @@ import 'package:test/test.dart'; void main() { group('resolvespec', () { 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( '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)])); + options: + const Options(limit: 5, filters: [FilterOption('a', 'eq', 1)])); final req = seen.single; expect(req.method, 'POST'); expect(req.url.path, '/public/users'); @@ -39,7 +47,8 @@ void main() { test('id placement', () async { 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); expect(seen.last.url.path, '/s/e/7'); 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.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( plain.read('s', 'e'), - throwsA(isA().having((e) => e.message, 'message', 'bad gateway')), + throwsA(isA() + .having((e) => e.message, 'message', 'bad gateway')), ); final soft = ResolveSpecClient( 'http://x', @@ -82,7 +93,10 @@ void main() { 'error': {'code': 'c', 'message': 'nope'} }).$1), ); - await expectLater(soft.read('s', 'e'), throwsA(isA().having((e) => e.message, 'message', 'nope'))); + await expectLater( + soft.read('s', 'e'), + throwsA(isA() + .having((e) => e.message, 'message', 'nope'))); }); }); @@ -135,27 +149,45 @@ void main() { }); test('query building', () { - expect(buildQuery({'a': true, 'b': ['x', 'y'], 'c': null, 'd': 3}), { - 'a': ['true'], - 'b': ['x', 'y'], - 'd': ['3'], - }); + 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)); + 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.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 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); @@ -165,14 +197,19 @@ void main() { ClientOptions( httpClient: stub(400, { '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), ); await expectLater( bad.query('api/u'), throwsA(isA() .having((e) => e.error.code, 'code', 'hook_error') - .having((e) => e.error.detail, 'detail', 'authentication required')), + .having( + (e) => e.error.detail, 'detail', 'authentication required')), ); }); });