diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e3eb1..42463cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.5 +- Added `Currency` +- Added `Restrr#retrieveAllCurrencies` +- Added `Restrr#createCurrency` +- Added `Restrr#retrieveCurrencyById` +- Added `Restrr#deleteCurrencyById` +- Added `Restrr#updateCurrencyById` +- Added `Restrr#retrieveSelf` +- Implemented (Batch)CacheViews (some retrieve methods now have a `forceRetrieve` parameter) + ## 0.4.2 - Fixed missing `isWeb` in `RestrrBuilder#create` diff --git a/lib/restrr.dart b/lib/restrr.dart index 9ef135c..6f56c48 100644 --- a/lib/restrr.dart +++ b/lib/restrr.dart @@ -5,6 +5,7 @@ export 'src/entity_builder.dart'; export 'src/restrr_base.dart'; /* [ /src/entities ] */ +export 'src/entities/currency.dart'; export 'src/entities/health_response.dart'; export 'src/entities/restrr_entity.dart'; export 'src/entities/user.dart'; diff --git a/lib/src/cache/batch_cache_view.dart b/lib/src/cache/batch_cache_view.dart new file mode 100644 index 0000000..f93e4c4 --- /dev/null +++ b/lib/src/cache/batch_cache_view.dart @@ -0,0 +1,13 @@ +import '../../restrr.dart'; + +class RestrrEntityBatchCacheView { + List? _lastSnapshot; + + List? get() => _lastSnapshot; + + void update(List value) => _lastSnapshot = value; + + void clear() => _lastSnapshot = null; + + bool get hasSnapshot => _lastSnapshot != null; +} \ No newline at end of file diff --git a/lib/src/cache/cache_view.dart b/lib/src/cache/cache_view.dart new file mode 100644 index 0000000..c2d7efb --- /dev/null +++ b/lib/src/cache/cache_view.dart @@ -0,0 +1,15 @@ +import 'package:restrr/restrr.dart'; + +class RestrrEntityCacheView { + final Map _cache = {}; + + T? get(ID id) => _cache[id]; + + T cache(T value) => _cache[value.id] = value; + + T? remove(ID id) => _cache.remove(id); + + void clear() => _cache.clear(); + + bool contains(ID id) => _cache.containsKey(id); +} diff --git a/lib/src/entities/currency.dart b/lib/src/entities/currency.dart new file mode 100644 index 0000000..89c9d5d --- /dev/null +++ b/lib/src/entities/currency.dart @@ -0,0 +1,42 @@ +import 'package:restrr/restrr.dart'; + +abstract class Currency implements RestrrEntity { + String get name; + String get symbol; + String get isoCode; + int get decimalPlaces; + int? get user; + + bool get isCustom; + + bool isCreatedBy(User user); +} + +class CurrencyImpl extends RestrrEntityImpl implements Currency { + @override + final String name; + @override + final String symbol; + @override + final String isoCode; + @override + final int decimalPlaces; + @override + final int? user; + + const CurrencyImpl({ + required super.api, + required super.id, + required this.name, + required this.symbol, + required this.isoCode, + required this.decimalPlaces, + required this.user, + }); + + @override + bool get isCustom => user != null; + + @override + bool isCreatedBy(User user) => this.user == user.id; +} diff --git a/lib/src/entities/restrr_entity.dart b/lib/src/entities/restrr_entity.dart index f568523..caeda28 100644 --- a/lib/src/entities/restrr_entity.dart +++ b/lib/src/entities/restrr_entity.dart @@ -1,17 +1,24 @@ import '../../restrr.dart'; +typedef ID = int; + /// The base class for all Restrr entities. /// This simply provides a reference to the Restrr instance. abstract class RestrrEntity { /// A reference to the Restrr instance. Restrr get api; + + ID get id; } class RestrrEntityImpl implements RestrrEntity { @override final Restrr api; + @override + final ID id; const RestrrEntityImpl({ required this.api, + required this.id, }); } diff --git a/lib/src/entities/user.dart b/lib/src/entities/user.dart index 6aae6f1..8937aad 100644 --- a/lib/src/entities/user.dart +++ b/lib/src/entities/user.dart @@ -1,7 +1,6 @@ import '../../restrr.dart'; abstract class User extends RestrrEntity { - int get id; String get username; String? get email; String? get displayName; @@ -14,8 +13,6 @@ abstract class User extends RestrrEntity { } class UserImpl extends RestrrEntityImpl implements User { - @override - final int id; @override final String username; @override @@ -29,7 +26,7 @@ class UserImpl extends RestrrEntityImpl implements User { const UserImpl({ required super.api, - required this.id, + required super.id, required this.username, required this.email, required this.displayName, diff --git a/lib/src/entity_builder.dart b/lib/src/entity_builder.dart index f5df14c..275adb5 100644 --- a/lib/src/entity_builder.dart +++ b/lib/src/entity_builder.dart @@ -15,7 +15,7 @@ class EntityBuilder { } User buildUser(Map json) { - return UserImpl( + final UserImpl user = UserImpl( api: api, id: json['id'], username: json['username'], @@ -24,5 +24,19 @@ class EntityBuilder { createdAt: DateTime.parse(json['created_at']), isAdmin: json['is_admin'], ); + return api.userCache.cache(user); + } + + Currency buildCurrency(Map json) { + final CurrencyImpl currency = CurrencyImpl( + api: api, + id: json['id'], + name: json['name'], + symbol: json['symbol'], + isoCode: json['iso_code'], + decimalPlaces: json['decimal_places'], + user: json['user'], + ); + return api.currencyCache.cache(currency); } } diff --git a/lib/src/requests/route.dart b/lib/src/requests/route.dart index c35a14d..93cd8ed 100644 --- a/lib/src/requests/route.dart +++ b/lib/src/requests/route.dart @@ -37,18 +37,18 @@ class Route { Route.patch(String path, {bool isVersioned = true}) : this._('PATCH', path, isVersioned: isVersioned); - CompiledRoute compile({List params = const []}) { + CompiledRoute compile({List params = const []}) { if (params.length != paramCount) { throw ArgumentError( 'Error compiling route [$method $path}]: Incorrect amount of parameters! Expected: $paramCount, Provided: ${params.length}'); } final Map values = {}; String compiledRoute = path; - for (String param in params) { + for (dynamic param in params) { int paramStart = compiledRoute.indexOf('{'); int paramEnd = compiledRoute.indexOf('}'); - values[compiledRoute.substring(paramStart + 1, paramEnd)] = param; - compiledRoute = compiledRoute.replaceRange(paramStart, paramEnd + 1, param); + values[compiledRoute.substring(paramStart + 1, paramEnd)] = param.toString(); + compiledRoute = compiledRoute.replaceRange(paramStart, paramEnd + 1, param.toString()); } return CompiledRoute(this, compiledRoute, values); } diff --git a/lib/src/requests/route_definitions.dart b/lib/src/requests/route_definitions.dart index 9057b4d..4a0b4eb 100644 --- a/lib/src/requests/route_definitions.dart +++ b/lib/src/requests/route_definitions.dart @@ -15,3 +15,13 @@ class UserRoutes { static final Route logout = Route.delete('/user/logout'); static final Route register = Route.post('/user/register'); } + +class CurrencyRoutes { + const CurrencyRoutes._(); + + static final Route retrieveAll = Route.get('/currency'); + static final Route create = Route.post('/currency/{currencyId}'); + static final Route retrieveById = Route.get('/currency/{currencyId}'); + static final Route deleteById = Route.delete('/currency/{currencyId}'); + static final Route updateById = Route.patch('/currency/{currencyId}'); +} diff --git a/lib/src/restrr_base.dart b/lib/src/restrr_base.dart index abb4192..a44cf83 100644 --- a/lib/src/restrr_base.dart +++ b/lib/src/restrr_base.dart @@ -1,9 +1,12 @@ import 'package:logging/logging.dart'; +import 'package:restrr/src/cache/batch_cache_view.dart'; import 'package:restrr/src/requests/route.dart'; import 'package:restrr/src/service/api_service.dart'; +import 'package:restrr/src/service/currency_service.dart'; import 'package:restrr/src/service/user_service.dart'; import '../restrr.dart'; +import 'cache/cache_view.dart'; class RestrrOptions { final bool isWeb; @@ -59,7 +62,7 @@ class RestrrBuilder { /// Logs in with the given [username] and [password]. Future> _handleLogin(RestrrImpl apiImpl, String username, String password) async { - final RestResponse response = await apiImpl.userService.login(username, password); + final RestResponse response = await apiImpl._userService.login(username, password); if (!response.hasData) { Restrr.log.warning('Invalid credentials for user $username'); return RestrrError.invalidCredentials.toRestResponse(statusCode: response.statusCode); @@ -73,7 +76,7 @@ class RestrrBuilder { Future> _handleRegistration(RestrrImpl apiImpl, String username, String password, {String? email, String? displayName}) async { final RestResponse response = - await apiImpl.userService.register(username, password, email: email, displayName: displayName); + await apiImpl._userService.register(username, password, email: email, displayName: displayName); if (response.hasError) { Restrr.log.warning('Failed to register user $username'); return response.error?.toRestResponse(statusCode: response.statusCode) ?? RestrrError.unknown.toRestResponse(); @@ -85,7 +88,7 @@ class RestrrBuilder { /// Attempts to refresh the session with still saved credentials. Future> _handleSavedSession(RestrrImpl apiImpl) async { - final RestResponse response = await apiImpl.userService.getSelf(); + final RestResponse response = await apiImpl._userService.getSelf(); if (response.hasError) { Restrr.log.warning('Failed to refresh session'); return response.error?.toRestResponse(statusCode: response.statusCode) ?? RestrrError.unknown.toRestResponse(); @@ -108,8 +111,6 @@ abstract class Restrr { /// The currently authenticated user. User get selfUser; - Future logout(); - /// Checks whether the given [uri] is valid and the API is healthy. static Future> checkUri(Uri uri, {bool isWeb = false}) async { return RequestHandler.request( @@ -118,6 +119,23 @@ abstract class Restrr { isWeb: isWeb, routeOptions: RouteOptions(hostUri: uri)); } + + /// Retrieves the currently authenticated user. + Future retrieveSelf({bool forceRetrieve = false}); + + /// Logs out the current user. + Future logout(); + + Future?> retrieveAllCurrencies({bool forceRetrieve = false}); + + Future createCurrency( + {required String name, required String symbol, required String isoCode, required int decimalPlaces}); + + Future retrieveCurrencyById(ID id, {bool forceRetrieve = false}); + + Future deleteCurrencyById(ID id); + + Future updateCurrencyById(ID id, {String? name, String? symbol, String? isoCode, int? decimalPlaces}); } class RestrrImpl implements Restrr { @@ -126,7 +144,17 @@ class RestrrImpl implements Restrr { @override final RouteOptions routeOptions; - late final UserService userService = UserService(api: this); + /* Services */ + + late final UserService _userService = UserService(api: this); + late final CurrencyService _currencyService = CurrencyService(api: this); + + /* Caches */ + + late final RestrrEntityCacheView userCache = RestrrEntityCacheView(); + late final RestrrEntityCacheView currencyCache = RestrrEntityCacheView(); + + late final RestrrEntityBatchCacheView _currencyBatchCache = RestrrEntityBatchCacheView(); RestrrImpl._({required this.options, required this.routeOptions}); @@ -136,13 +164,89 @@ class RestrrImpl implements Restrr { @override late final User selfUser; + @override + Future retrieveSelf({bool forceRetrieve = false}) async { + return _getOrRetrieveSingle( + key: selfUser.id, + cacheView: userCache, + retrieveFunction: (api) => api._userService.getSelf(), + forceRetrieve: forceRetrieve); + } + @override Future logout() async { - final RestResponse response = await UserService(api: this).logout(); + final RestResponse response = await _userService.logout(); if (response.hasData && response.data! && !options.isWeb) { await CompiledRoute.cookieJar.deleteAll(); return true; } return false; } + + @override + Future?> retrieveAllCurrencies({bool forceRetrieve = false}) async { + return _getOrRetrieveMulti( + batchCache: _currencyBatchCache, + retrieveFunction: (api) => api._currencyService.retrieveAllCurrencies(), + ); + } + + @override + Future createCurrency( + {required String name, required String symbol, required String isoCode, required int decimalPlaces}) async { + final RestResponse response = await _currencyService.createCurrency( + name: name, symbol: symbol, isoCode: isoCode, decimalPlaces: decimalPlaces); + return response.data; + } + + @override + Future retrieveCurrencyById(ID id, {bool forceRetrieve = false}) async { + return _getOrRetrieveSingle( + key: id, + cacheView: currencyCache, + retrieveFunction: (api) => api._currencyService.retrieveCurrencyById(id), + forceRetrieve: forceRetrieve); + } + + @override + Future deleteCurrencyById(ID id) async { + final RestResponse response = await _currencyService.deleteCurrencyById(id); + return response.hasData && response.data!; + } + + @override + Future updateCurrencyById(ID id, + {String? name, String? symbol, String? isoCode, int? decimalPlaces}) async { + final RestResponse response = await _currencyService.updateCurrencyById(id, + name: name, symbol: symbol, isoCode: isoCode, decimalPlaces: decimalPlaces); + return response.data; + } + + Future _getOrRetrieveSingle( + {required ID key, + required RestrrEntityCacheView cacheView, + required Future> Function(RestrrImpl) retrieveFunction, + bool forceRetrieve = false}) async { + if (!forceRetrieve && cacheView.contains(key)) { + return cacheView.get(key)!; + } + final RestResponse response = await retrieveFunction.call(this); + return response.hasData ? response.data : null; + } + + Future?> _getOrRetrieveMulti( + {required RestrrEntityBatchCacheView batchCache, + required Future>> Function(RestrrImpl) retrieveFunction, + bool forceRetrieve = false}) async { + if (!forceRetrieve && batchCache.hasSnapshot) { + return batchCache.get()!; + } + final RestResponse> response = await retrieveFunction.call(this); + if (response.hasData) { + final List remote = response.data!; + batchCache.update(remote); + return remote; + } + return null; + } } diff --git a/lib/src/service/currency_service.dart b/lib/src/service/currency_service.dart new file mode 100644 index 0000000..a89626b --- /dev/null +++ b/lib/src/service/currency_service.dart @@ -0,0 +1,69 @@ +import 'package:restrr/restrr.dart'; + +import 'api_service.dart'; + +class CurrencyService extends ApiService { + const CurrencyService({required super.api}); + + Future>> retrieveAllCurrencies() async { + return multiRequest( + route: CurrencyRoutes.retrieveAll.compile(), + mapper: (json) => api.entityBuilder.buildCurrency(json), + errorMap: { + 401: RestrrError.notSignedIn, + }); + } + + Future> createCurrency( + {required String name, required String symbol, required String isoCode, required int decimalPlaces}) async { + return request( + route: CurrencyRoutes.create.compile(), + mapper: (json) => api.entityBuilder.buildCurrency(json), + body: { + 'name': name, + 'symbol': symbol, + 'iso_code': isoCode, + 'decimal_places': decimalPlaces, + }, + errorMap: { + 401: RestrrError.notSignedIn, + }); + } + + Future> retrieveCurrencyById(ID id) async { + return request( + route: CurrencyRoutes.retrieveById.compile(params: [id]), + mapper: (json) => api.entityBuilder.buildCurrency(json), + errorMap: { + 401: RestrrError.notSignedIn, + 404: RestrrError.notFound, + }); + } + + Future> deleteCurrencyById(ID id) async { + return noResponseRequest(route: CurrencyRoutes.deleteById.compile(params: [id]), errorMap: { + 401: RestrrError.notSignedIn, + 404: RestrrError.notFound, + }); + } + + Future> updateCurrencyById(ID id, + {String? name, String? symbol, String? isoCode, int? decimalPlaces}) async { + if (name == null && symbol == null && isoCode == null && decimalPlaces == null) { + throw ArgumentError('At least one field must be set'); + } + return request( + route: CurrencyRoutes.updateById.compile(params: [id]), + mapper: (json) => api.entityBuilder.buildCurrency(json), + body: { + if (name != null) 'name': name, + if (symbol != null) 'symbol': symbol, + if (isoCode != null) 'iso_code': isoCode, + if (decimalPlaces != null) 'decimal_places': decimalPlaces, + }, + errorMap: { + 401: RestrrError.notSignedIn, + 404: RestrrError.notFound, + }); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e1652bc..401ab90 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: restrr description: Dart package which allows to communicate with the financrr REST API. -version: 0.4.2 +version: 0.5.0 repository: https://github.com/financrr/restrr environment: diff --git a/test/restrr_entity_test.dart b/test/restrr_entity_test.dart index ed4f353..5a5c47f 100644 --- a/test/restrr_entity_test.dart +++ b/test/restrr_entity_test.dart @@ -24,6 +24,17 @@ const String userJson = ''' } '''; +const String currencyJson = ''' +{ + "id": 1, + "name": "US Dollar", + "symbol": "\$", + "iso_code": "USD", + "decimal_places": 2, + "user": 1 +} +'''; + void main() { late Restrr api; @@ -40,7 +51,7 @@ void main() { expect(healthResponse.details, null); }); - test('.buildUser', () { + test('.buildUser', () async { final User user = api.entityBuilder.buildUser(jsonDecode(userJson)); expect(user.id, 1); expect(user.username, 'admin'); @@ -48,5 +59,15 @@ void main() { expect(user.createdAt, DateTime.parse('+002024-02-17T20:48:43.391176000Z')); expect(user.isAdmin, true); }); + + test('.buildCurrency', () { + final Currency currency = api.entityBuilder.buildCurrency(jsonDecode(currencyJson)); + expect(currency.id, 1); + expect(currency.name, 'US Dollar'); + expect(currency.symbol, '\$'); + expect(currency.isoCode, 'USD'); + expect(currency.decimalPlaces, 2); + expect(currency.user, 1); + }); }); }