diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 929420bc..a27056d4 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -10,7 +10,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 - name: Dart version run: | @@ -32,7 +32,7 @@ jobs: test_vm: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 - name: Dart version run: | @@ -63,7 +63,7 @@ jobs: test_chrome: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 - name: Dart version run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2856e863..a073eeaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## 1.9.3-beta.2 + +- New `DBAdapterCapabilityConnectivity`. + +- `DBAdapterCapability`: + - Added field `connectivity`. + +- `DBPostgreSQLAdapter`: + - Added field `onlySecureConnections`. + +- dependency_validator: ^4.1.3 + +## 1.9.3-beta.1 + +- `DBPostgreSQLAdapter`: + - Upgrade to `postgres` API v3. + - Allow SSL connections. + +- `Time.parse`: accept format `Time(hh:mm:ss.sss)` + +- postgres: ^3.5.4 +- project_template: ^1.1.1 +- archive: ^4.0.4 + ## 1.9.2 - `FieldsFromMap`: diff --git a/lib/src/bones_api_base.dart b/lib/src/bones_api_base.dart index 3e941522..ddff4efc 100644 --- a/lib/src/bones_api_base.dart +++ b/lib/src/bones_api_base.dart @@ -42,7 +42,7 @@ typedef APILogger = void Function(APIRoot apiRoot, String type, String? message, /// Bones API Library class. class BonesAPI { // ignore: constant_identifier_names - static const String VERSION = '1.9.2'; + static const String VERSION = '1.9.3-beta.2'; static bool _boot = false; diff --git a/lib/src/bones_api_db_module.dart b/lib/src/bones_api_db_module.dart index 85289b93..a234b0ca 100644 --- a/lib/src/bones_api_db_module.dart +++ b/lib/src/bones_api_db_module.dart @@ -842,7 +842,7 @@ class APIDBModule extends APIModule { var zipEncoder = ZipEncoder(); - var encode = zipEncoder.encode(archive)!; + var encode = zipEncoder.encode(archive); var zipData = encode is Uint8List ? encode : Uint8List.fromList(encode); var zipFileName = '$zipName.zip'; diff --git a/lib/src/bones_api_entity_db.dart b/lib/src/bones_api_entity_db.dart index a9be5cbe..dcef191a 100644 --- a/lib/src/bones_api_entity_db.dart +++ b/lib/src/bones_api_entity_db.dart @@ -29,6 +29,14 @@ class DBDialect { } } +/// The [DBAdapterCapability.connectivity]. +enum DBAdapterCapabilityConnectivity { + none, + unsecure, + secure, + secureAndUnsecure; +} + /// [DBAdapter] capabilities. class DBAdapterCapability implements WithRuntimeTypeNameSafe { /// The dialect of the DB. @@ -50,16 +58,20 @@ class DBAdapterCapability implements WithRuntimeTypeNameSafe { /// - If an adapter tries to initialize inside an auxiliary [Isolate] ([DBAdapter.auxiliaryMode]), it will fail. final bool multiIsolateSupport; + /// The supported connection types. + final DBAdapterCapabilityConnectivity connectivity; + const DBAdapterCapability({ required this.dialect, required this.transactions, required this.transactionAbort, required this.constraintSupport, required this.multiIsolateSupport, + required this.connectivity, }); String get info => - 'transactions: $transactions, transactionAbort: $transactionAbort, constraintSupport: $constraintSupport, multiIsolateSupport: $multiIsolateSupport'; + 'transactions: $transactions, transactionAbort: $transactionAbort, constraintSupport: $constraintSupport, multiIsolateSupport: $multiIsolateSupport, connectivity: ${connectivity.name}'; @override String get runtimeTypeNameSafe => 'DBAdapterCapability'; diff --git a/lib/src/bones_api_entity_db_memory.dart b/lib/src/bones_api_entity_db_memory.dart index e92e8e9b..cf4c282e 100644 --- a/lib/src/bones_api_entity_db_memory.dart +++ b/lib/src/bones_api_entity_db_memory.dart @@ -109,7 +109,8 @@ class DBSQLMemoryAdapter extends DBSQLAdapter transactionAbort: true, tableSQL: false, constraintSupport: false, - multiIsolateSupport: false), + multiIsolateSupport: false, + connectivity: DBAdapterCapabilityConnectivity.none), ) { boot(); diff --git a/lib/src/bones_api_entity_db_mysql.dart b/lib/src/bones_api_entity_db_mysql.dart index 35826fb0..010702d9 100644 --- a/lib/src/bones_api_entity_db_mysql.dart +++ b/lib/src/bones_api_entity_db_mysql.dart @@ -105,7 +105,8 @@ class DBMySQLAdapter extends DBSQLAdapter transactionAbort: true, tableSQL: true, constraintSupport: false, - multiIsolateSupport: true), + multiIsolateSupport: true, + connectivity: DBAdapterCapabilityConnectivity.secureAndUnsecure), ) { boot(); diff --git a/lib/src/bones_api_entity_db_object_directory.dart b/lib/src/bones_api_entity_db_object_directory.dart index a9393864..d33655eb 100644 --- a/lib/src/bones_api_entity_db_object_directory.dart +++ b/lib/src/bones_api_entity_db_object_directory.dart @@ -92,7 +92,8 @@ class DBObjectDirectoryAdapter transactions: true, transactionAbort: true, constraintSupport: false, - multiIsolateSupport: true), + multiIsolateSupport: true, + connectivity: DBAdapterCapabilityConnectivity.none), ) { boot(); diff --git a/lib/src/bones_api_entity_db_object_gcs.dart b/lib/src/bones_api_entity_db_object_gcs.dart index 8785f1e5..1501b39f 100644 --- a/lib/src/bones_api_entity_db_object_gcs.dart +++ b/lib/src/bones_api_entity_db_object_gcs.dart @@ -106,7 +106,8 @@ class DBObjectGCSAdapter extends DBObjectAdapter { transactions: true, transactionAbort: true, constraintSupport: false, - multiIsolateSupport: true), + multiIsolateSupport: true, + connectivity: DBAdapterCapabilityConnectivity.secure), ) { boot(); diff --git a/lib/src/bones_api_entity_db_object_memory.dart b/lib/src/bones_api_entity_db_object_memory.dart index f1b40d93..3caa910e 100644 --- a/lib/src/bones_api_entity_db_object_memory.dart +++ b/lib/src/bones_api_entity_db_object_memory.dart @@ -91,7 +91,8 @@ class DBObjectMemoryAdapter transactions: true, transactionAbort: true, constraintSupport: false, - multiIsolateSupport: false), + multiIsolateSupport: false, + connectivity: DBAdapterCapabilityConnectivity.none), ) { boot(); diff --git a/lib/src/bones_api_entity_db_postgres.dart b/lib/src/bones_api_entity_db_postgres.dart index 67de53fb..d67dea10 100644 --- a/lib/src/bones_api_entity_db_postgres.dart +++ b/lib/src/bones_api_entity_db_postgres.dart @@ -1,6 +1,6 @@ import 'package:async_extension/async_extension.dart'; import 'package:logging/logging.dart' as logging; -import 'package:postgres/postgres.dart'; +import 'package:postgres/postgres.dart' hide Time, Type; import 'package:reflection_factory/reflection_factory.dart'; import 'package:statistics/statistics.dart'; @@ -15,7 +15,6 @@ import 'bones_api_logging.dart'; import 'bones_api_sql_builder.dart'; import 'bones_api_types.dart'; import 'bones_api_utils.dart'; -import 'bones_api_utils_call.dart'; import 'bones_api_utils_timedmap.dart'; final _log = logging.Logger('DBPostgreSQLAdapter')..registerAsDbLogger(); @@ -34,7 +33,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter DBSQLAdapter.boot(); - Transaction.registerErrorFilter((e, s) => e is PostgreSQLException); + Transaction.registerErrorFilter((e, s) => e is PgException); DBSQLAdapter.registerAdapter([ 'sql.postgres', @@ -72,6 +71,8 @@ class DBPostgreSQLAdapter extends DBSQLAdapter final String? _password; final PasswordProvider? _passwordProvider; + final bool onlySecureConnections; + DBPostgreSQLAdapter(this.databaseName, this.username, {String? host = 'localhost', Object? password, @@ -79,6 +80,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter int? port = 5432, int minConnections = 1, int maxConnections = 3, + this.onlySecureConnections = false, super.generateTables, super.checkTables, super.populateTables, @@ -111,7 +113,8 @@ class DBPostgreSQLAdapter extends DBSQLAdapter transactionAbort: true, tableSQL: true, constraintSupport: true, - multiIsolateSupport: true), + multiIsolateSupport: true, + connectivity: DBAdapterCapabilityConnectivity.secureAndUnsecure), ) { boot(); @@ -175,6 +178,9 @@ class DBPostgreSQLAdapter extends DBSQLAdapter var logSql = DBSQLAdapter.parseConfigLogSQL(config) ?? false; + var onlySecureConnections = + TypeParser.parseBool(config?['onlySecureConnections'], false)!; + return DBPostgreSQLAdapter( database, username, @@ -191,6 +197,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter parentRepositoryProvider: parentRepositoryProvider, workingPath: workingPath, logSQL: logSql, + onlySecureConnections: onlySecureConnections, ); } @@ -232,8 +239,8 @@ class DBPostgreSQLAdapter extends DBSQLAdapter Object? previousError) { if (error is DBPostgreSQLAdapterException) { return error; - } else if (error is PostgreSQLException) { - if (error.severity == PostgreSQLSeverity.error) { + } else if (error is ServerException) { + if (error.severity == Severity.error) { if (error.code == '23505') { return EntityFieldInvalid("unique", error.detail, fieldName: error.columnName, @@ -272,7 +279,8 @@ class DBPostgreSQLAdapter extends DBSQLAdapter var count = ++_connectionCount; for (var i = 0; i < 3; ++i) { - var timeout = i == 0 ? 3 : (i == 1 ? 10 : 30); + final timeoutSec = i == 0 ? 3 : (i == 1 ? 10 : 30); + final timeout = Duration(seconds: timeoutSec); var connection = await _createConnectionImpl(password, timeout); @@ -298,7 +306,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter return _createConnectionImpl(password, timeout).then((conn) { if (conn == null) { - var error = PostgreSQLException( + var error = PgException( "Error connecting to: $databaseName@$host:$port"); _log.severe( @@ -313,8 +321,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter } } - var error = - PostgreSQLException("Error connecting to: $databaseName@$host:$port"); + var error = PgException("Error connecting to: $databaseName@$host:$port"); _log.severe("Can't connect to PostgreSQL: $databaseName@$host:$port"); @@ -322,25 +329,103 @@ class DBPostgreSQLAdapter extends DBSQLAdapter } Future _createConnectionImpl( - String password, int timeout) async { - var connection = PostgreSQLConnection(host, port, databaseName, - username: username, password: password, timeoutInSeconds: timeout); - var ok = await tryCallMapped(() => connection.open(), - onSuccessValue: true, onErrorValue: false); + String password, Duration timeout) async { + final endpoint = Endpoint( + host: host, + port: port, + database: databaseName, + username: username, + password: password); + + Connection? connection; + if (onlySecureConnections) { + connection = await _connectSSLImpl(endpoint, timeout); + } else { + connection = await (_lastConnectSSLSupported + ? _connectSSLImpl(endpoint, timeout) + : _connectNoSSLImpl(endpoint, timeout)); + } - if (ok == null || !ok) return null; + if (connection == null) return null; - var connWrapper = PostgreSQLConnectionWrapper(connection); + var connWrapper = PostgreSQLConnectionWrapper(connection, endpoint); _connectionFinalizer.attach(connWrapper, connection); return connWrapper; } - late final Finalizer _connectionFinalizer = + var _lastConnectSSLSupported = true; + + Future _connectSSLImpl( + Endpoint endpoint, Duration timeout) async { + try { + var connection = await Connection.open( + endpoint, + settings: ConnectionSettings( + connectTimeout: timeout, + sslMode: SslMode.require, + ), + ); + _lastConnectSSLSupported = true; + return connection; + } on PgException catch (e) { + if (!onlySecureConnections && + e.severity == Severity.error && + e.message.contains('not support SSL')) { + try { + var connection = await Connection.open( + endpoint, + settings: ConnectionSettings( + connectTimeout: timeout, + sslMode: SslMode.disable, + ), + ); + _lastConnectSSLSupported = false; + return connection; + } catch (_) {} + } + + return null; + } + } + + Future _connectNoSSLImpl( + Endpoint endpoint, Duration timeout) async { + try { + var connection = await Connection.open( + endpoint, + settings: ConnectionSettings( + connectTimeout: timeout, + sslMode: SslMode.disable, + ), + ); + _lastConnectSSLSupported = false; + return connection; + } on PgException catch (e) { + if (e.severity == Severity.error && + e.message.contains('not support SSL')) { + try { + var connection = await Connection.open( + endpoint, + settings: ConnectionSettings( + connectTimeout: timeout, + sslMode: SslMode.require, + ), + ); + _lastConnectSSLSupported = true; + return connection; + } catch (_) {} + } + + return null; + } + } + + late final Finalizer _connectionFinalizer = Finalizer(_finalizeConnection); - void _finalizeConnection(PostgreSQLConnection connection) { + void _finalizeConnection(Connection connection) { try { // ignore: discarded_futures connection.close(); @@ -401,9 +486,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter var sql = "SELECT column_name, data_type, column_default, is_updatable FROM information_schema.columns WHERE table_name = '$table'"; - var results = await connection.mappedResultsQuery(sql); - - var scheme = results.map((e) => e['']!).toList(growable: false); + var scheme = await connection.mappedResultsQuery(sql); await releaseIntoPool(connection); @@ -434,9 +517,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter var sql = "SELECT column_name, data_type, column_default, is_updatable FROM information_schema.columns WHERE table_name = '$table'"; - var results = await connection.mappedResultsQuery(sql); - - var scheme = results.map((e) => e['']!).toList(growable: false); + var scheme = await connection.mappedResultsQuery(sql); if (scheme.isEmpty) { await releaseIntoPool(connection); @@ -495,11 +576,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter tbl.relname = '$table'; '''; - var results = await connection.mappedResultsQuery(sql); - - var columns = results.map((r) { - return Map.fromEntries(r.values.expand((e) => e.entries)); - }).toList(growable: false); + var columns = await connection.mappedResultsQuery(sql); var constraintsDefinitions = columns.map((m) => m['constraint_definition'].toString()).toList(); @@ -584,11 +661,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter constraint_type = 'PRIMARY KEY' and tc.table_name = '$table'; '''; - var results = await connection.mappedResultsQuery(sql); - - var columns = results.map((r) { - return Map.fromEntries(r.values.expand((e) => e.entries)); - }).toList(growable: false); + var columns = await connection.mappedResultsQuery(sql); var primaryFields = Map.fromEntries(columns .map((m) => MapEntry(m['column_name'].toString(), m['data_type']))); @@ -734,13 +807,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter var results = await connection.mappedResultsQuery(sql); - var names = results - .map((e) { - var v = e.values.first; - return v.values.first; - }) - .map((e) => '$e') - .toList(); + var names = results.map((e) => e.values.first).map((e) => '$e').toList(); return names; } @@ -795,11 +862,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter o.contype = 'f' AND m.relname = '$table' AND o.conrelid IN (SELECT oid FROM pg_class c WHERE c.relkind = 'r') '''; - var results = await connection.mappedResultsQuery(sql); - - var referenceFields = results.map((r) { - return Map.fromEntries(r.values.expand((e) => e.entries)); - }).toList(growable: false); + var referenceFields = await connection.mappedResultsQuery(sql); var map = Map.fromEntries(referenceFields.map((e) { @@ -871,9 +934,8 @@ class DBPostgreSQLAdapter extends DBSQLAdapter substitutionValues: sql.parametersByPlaceholder) .resolveMapped((results) { var count = results - .map((e) { - var tableResults = e[table] ?? e['']; - var count = tableResults?['count'] ?? 0; + .map((row) { + var count = row['count'] ?? 0; return count is int ? count : int.tryParse(count.toString().trim()); }) .whereType() @@ -895,11 +957,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter .mappedResultsQuery(sql.sql, substitutionValues: sql.parametersByPlaceholder) .resolveMapped((results) { - var ids = results - .map((e) => e[table]) - .whereType>() - .map((e) => e['id']); - + var ids = results.map((row) => _resolveReturningID(row, sql)); return parseIDs(ids); }); } @@ -913,17 +971,8 @@ class DBPostgreSQLAdapter extends DBSQLAdapter PostgreSQLConnectionWrapper connection) { if (sql.isDummy) return >[]; - return connection - .mappedResultsQuery(sql.sql, - substitutionValues: sql.parametersByPlaceholder) - .resolveMapped((results) { - var entries = results - .map((e) => e[table]) - .whereType>() - .toList(); - - return entries; - }); + return connection.mappedResultsQuery(sql.sql, + substitutionValues: sql.parametersByPlaceholder); } @override @@ -935,17 +984,8 @@ class DBPostgreSQLAdapter extends DBSQLAdapter PostgreSQLConnectionWrapper connection) { if (sql.isDummy) return >[]; - return connection - .mappedResultsQuery(sql.sql, - substitutionValues: sql.parametersByPlaceholder) - .resolveMapped((results) { - var entries = results - .map((e) => e[table]) - .whereType>() - .toList(); - - return entries; - }); + return connection.mappedResultsQuery(sql.sql, + substitutionValues: sql.parametersByPlaceholder); } @override @@ -1039,14 +1079,18 @@ class DBPostgreSQLAdapter extends DBSQLAdapter } dynamic _resolveResultID( - List>> results, String table, SQL sql) { + List> results, String table, SQL sql) { if (results.isEmpty) { return null; } - var returning = results.first[table]; + var returning = results.first; + + return _resolveReturningID(returning, sql); + } - if (returning == null || returning.isEmpty) { + dynamic _resolveReturningID(Map returning, SQL sql) { + if (returning.isEmpty) { return null; } else if (returning.length == 1) { var id = returning.values.first; @@ -1129,33 +1173,48 @@ class DBPostgreSQLAdapter extends DBSQLAdapter } /// A [DBPostgreSQLAdapter] connection wrapper. -class PostgreSQLConnectionWrapper - extends DBConnectionWrapper { - PostgreSQLConnectionWrapper(super.nativeConnection); +class PostgreSQLConnectionWrapper extends DBConnectionWrapper { + final Endpoint _endpoint; + + PostgreSQLConnectionWrapper(super.nativeConnection, this._endpoint); @override String get connectionURL { - var c = nativeConnection as PostgreSQLConnection; - return 'postgresql://${c.username}@${c.host}:${c.port}/${c.databaseName}'; + return 'postgresql://${_endpoint.username}@${_endpoint.host}:${_endpoint.port}/${_endpoint.database}'; } - Future>>> mappedResultsQuery(String sql, - {Map? substitutionValues}) { + Future>> mappedResultsQuery(String sql, + {Map? substitutionValues}) async { updateLastAccessTime(); - return nativeConnection.mappedResultsQuery(sql, - substitutionValues: substitutionValues); + + var rs = await nativeConnection.execute( + Sql.named(sql), + parameters: substitutionValues, + ); + + var mappedResult = rs.map((e) => e.toResultsMap()).toList(); + + return mappedResult; } - Future query(String sql, - {Map? substitutionValues}) { + Future query(String sql, + {Map? substitutionValues}) async { updateLastAccessTime(); - return nativeConnection.query(sql, substitutionValues: substitutionValues); + return nativeConnection.execute( + Sql.named(sql), + parameters: substitutionValues, + ); } - Future execute(String sql, {Map? substitutionValues}) { + Future execute(String sql, + {Map? substitutionValues}) async { updateLastAccessTime(); - return nativeConnection.execute(sql, - substitutionValues: substitutionValues); + var rs = await nativeConnection.execute( + Sql.named(sql), + parameters: substitutionValues, + ignoreRows: true, + ); + return rs.affectedRows; } Future openTransaction( @@ -1163,23 +1222,26 @@ class PostgreSQLConnectionWrapper queryBlock, { int? commitTimeoutInSeconds, }) { - var conn = nativeConnection as PostgreSQLConnection; + var conn = nativeConnection as Connection; updateLastAccessTime(); - return conn.transaction((transactionContext) => queryBlock( - PostgreSQLConnectionTransactionWrapper(this, transactionContext))); + + return conn.runTx( + (tx) => queryBlock( + PostgreSQLConnectionTransactionWrapper(this, tx, _endpoint), + ), + ); } @override bool isClosedImpl() { final nativeConnection = this.nativeConnection; - return nativeConnection is PostgreSQLConnection && - nativeConnection.isClosed; + return nativeConnection is Connection && !nativeConnection.isOpen; } @override void closeImpl() { final nativeConnection = this.nativeConnection; - if (nativeConnection is PostgreSQLConnection) { + if (nativeConnection is Connection) { try { // ignore: discarded_futures nativeConnection.close(); @@ -1196,7 +1258,8 @@ class PostgreSQLConnectionTransactionWrapper extends PostgreSQLConnectionWrapper { final PostgreSQLConnectionWrapper parent; - PostgreSQLConnectionTransactionWrapper(this.parent, super.nativeConnection); + PostgreSQLConnectionTransactionWrapper( + this.parent, super.nativeConnection, super.endpoint); @override Future openTransaction( @@ -1206,8 +1269,10 @@ class PostgreSQLConnectionTransactionWrapper {int? commitTimeoutInSeconds}) => queryBlock(this); - void cancelTransaction({String? reason}) => - nativeConnection.cancelTransaction(reason: reason); + void cancelTransaction({String? reason}) { + var tx = nativeConnection as TxSession; + unawaited(tx.rollback()); + } @override String get runtimeTypeNameSafe => 'PostgreSQLConnectionTransactionWrapper'; @@ -1227,3 +1292,19 @@ class DBPostgreSQLAdapterException extends DBSQLAdapterException { super.operation, super.previousError}); } + +extension on ResultRow { + Map toResultsMap() { + final map = {}; + + for (final (i, col) in schema.columns.indexed) { + if (col.columnName case final String name) { + map[name] = this[i]; + } else { + map['[$i]'] = this[i]; + } + } + + return map; + } +} diff --git a/lib/src/bones_api_entity_db_sql.dart b/lib/src/bones_api_entity_db_sql.dart index b7c3951d..be08c46b 100644 --- a/lib/src/bones_api_entity_db_sql.dart +++ b/lib/src/bones_api_entity_db_sql.dart @@ -23,6 +23,8 @@ import 'bones_api_types.dart'; import 'bones_api_utils_collections.dart'; import 'bones_api_utils_json.dart'; +export 'bones_api_entity_db.dart' show DBAdapterCapabilityConnectivity; + final _log = logging.Logger('SQLAdapter')..registerAsDbLogger(); /// [SQL] wrapper interface. @@ -287,7 +289,8 @@ class DBSQLAdapterCapability extends DBAdapterCapability { required super.transactionAbort, required super.constraintSupport, required this.tableSQL, - required super.multiIsolateSupport}); + required super.multiIsolateSupport, + required super.connectivity}); @override String get info => '${super.info}, tableSQL: $tableSQL'; diff --git a/lib/src/bones_api_types.dart b/lib/src/bones_api_types.dart index 66424a9f..54e2497a 100644 --- a/lib/src/bones_api_types.dart +++ b/lib/src/bones_api_types.dart @@ -183,6 +183,10 @@ class Time implements Comparable