From 6ea4e9eff6d23ad9b10f8d87f41080681e655962 Mon Sep 17 00:00:00 2001 From: Jason Dean Lessenich Date: Mon, 26 Feb 2024 18:07:44 +0100 Subject: [PATCH] =?UTF-8?q?0.4=20=E2=80=94=20Improved=20Route=20Handling?= =?UTF-8?q?=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added isWeb flag to Route#compile which disables caching and network checks * Moved isWeb flag to ApiService * Moved route config to `RouteOptions` * Added `statusCode` to `RestResponse` * Some more cleanup * dart format --- CHANGELOG.md | 5 + lib/restrr.dart | 5 - lib/src/requests/responses/rest_response.dart | 4 +- lib/src/requests/responses/restrr_errors.dart | 2 +- lib/src/requests/route.dart | 41 +++--- lib/src/requests/route_definitions.dart | 2 +- lib/src/restrr_base.dart | 119 ++++++++---------- lib/src/service/api_service.dart | 104 ++++++++++++--- lib/src/service/user_service.dart | 16 ++- pubspec.yaml | 2 +- 10 files changed, 176 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac1da61..24ed7dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.4 +- Added `statusCode` to `RestResponse` +- Moved route config to `RouteOptions` +- Removed export of `ApiService`s + ## 0.3.3 - Fixed missing options param diff --git a/lib/restrr.dart b/lib/restrr.dart index fed04b1..9ef135c 100644 --- a/lib/restrr.dart +++ b/lib/restrr.dart @@ -10,17 +10,12 @@ export 'src/entities/restrr_entity.dart'; export 'src/entities/user.dart'; /* [ /src/requests ] */ -export 'src/requests/route.dart'; export 'src/requests/route_definitions.dart'; /* [ /src/requests/responses ] */ export 'src/requests/responses/restrr_errors.dart'; export 'src/requests/responses/rest_response.dart'; -/* [ /src/service ] */ -export 'src/service/api_service.dart'; -export 'src/service/user_service.dart'; - /* [ /src/utils ] */ export 'src/utils/io_utils.dart'; export 'src/utils/string_utils.dart'; diff --git a/lib/src/requests/responses/rest_response.dart b/lib/src/requests/responses/rest_response.dart index ebfcf6b..f0cdee5 100644 --- a/lib/src/requests/responses/rest_response.dart +++ b/lib/src/requests/responses/rest_response.dart @@ -5,9 +5,11 @@ import '../../../restrr.dart'; class RestResponse { final T? data; final RestrrError? error; + final int? statusCode; - const RestResponse({this.data, this.error}); + const RestResponse({this.data, this.error, required this.statusCode}); bool get hasData => data != null; bool get hasError => error != null; + bool get hasStatusCode => statusCode != null; } diff --git a/lib/src/requests/responses/restrr_errors.dart b/lib/src/requests/responses/restrr_errors.dart index f612c6d..bb2dceb 100644 --- a/lib/src/requests/responses/restrr_errors.dart +++ b/lib/src/requests/responses/restrr_errors.dart @@ -25,5 +25,5 @@ enum RestrrError { const RestrrError({this.clientError = false}); - RestResponse toRestResponse() => RestResponse(error: this); + RestResponse toRestResponse({int? statusCode}) => RestResponse(error: this, statusCode: statusCode); } diff --git a/lib/src/requests/route.dart b/lib/src/requests/route.dart index df5579d..c35a14d 100644 --- a/lib/src/requests/route.dart +++ b/lib/src/requests/route.dart @@ -4,6 +4,12 @@ import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import '../../restrr.dart'; +class RouteOptions { + final Uri hostUri; + final int apiVersion; + const RouteOptions({required this.hostUri, this.apiVersion = -1}); +} + class Route { /// The HTTP method of this route. final String method; @@ -49,7 +55,7 @@ class Route { } class CompiledRoute { - static CookieJar? cookieJar; + static final CookieJar cookieJar = PersistCookieJar(); final Route baseRoute; final String compiledRoute; @@ -68,30 +74,29 @@ class CompiledRoute { return CompiledRoute(baseRoute, newRoute, parameters, queryParameters: queryParameters); } - Future submit({dynamic body, String contentType = 'application/json'}) { - if (!Restrr.hostInformation.hasHostUrl) { - throw StateError('Host URL is not set!'); + Future submit( + {required RouteOptions routeOptions, dynamic body, bool isWeb = false, String contentType = 'application/json'}) { + if (baseRoute.isVersioned && routeOptions.apiVersion == -1) { + throw StateError('Cannot submit a versioned route without specifying the API version!'); } Dio dio = Dio(); - if (cookieJar != null) { - dio.interceptors.add(CookieManager(cookieJar!)); + if (!isWeb) { + dio.interceptors.add(CookieManager(cookieJar)); } - Map headers = {}; - headers['Content-Type'] = contentType; - return dio - .fetch(RequestOptions( - path: compiledRoute, - headers: headers, - data: body, - method: baseRoute.method.toString(), - baseUrl: _buildBaseUrl(Restrr.hostInformation, baseRoute.isVersioned))); + Map headers = {'Content-Type': contentType}; + return dio.fetch(RequestOptions( + path: compiledRoute, + headers: headers, + data: body, + method: baseRoute.method.toString(), + baseUrl: _buildBaseUrl(routeOptions, baseRoute.isVersioned))); } - String _buildBaseUrl(HostInformation hostInformation, bool isVersioned) { - String effectiveHostUrl = hostInformation.hostUri!.toString(); + String _buildBaseUrl(RouteOptions options, bool isVersioned) { + String effectiveHostUrl = options.hostUri.toString(); if (effectiveHostUrl.endsWith('/')) { effectiveHostUrl = effectiveHostUrl.substring(0, effectiveHostUrl.length - 1); } - return isVersioned ? '$effectiveHostUrl/api/v${hostInformation.apiVersion}' : '$effectiveHostUrl/api'; + return isVersioned ? '$effectiveHostUrl/api/v${options.apiVersion}' : '$effectiveHostUrl/api'; } } diff --git a/lib/src/requests/route_definitions.dart b/lib/src/requests/route_definitions.dart index 450c02f..9057b4d 100644 --- a/lib/src/requests/route_definitions.dart +++ b/lib/src/requests/route_definitions.dart @@ -1,4 +1,4 @@ -import '../../restrr.dart'; +import 'package:restrr/src/requests/route.dart'; class StatusRoutes { const StatusRoutes._(); diff --git a/lib/src/restrr_base.dart b/lib/src/restrr_base.dart index cc833b3..99207ed 100644 --- a/lib/src/restrr_base.dart +++ b/lib/src/restrr_base.dart @@ -1,38 +1,16 @@ -import 'package:cookie_jar/cookie_jar.dart'; import 'package:logging/logging.dart'; +import 'package:restrr/src/requests/route.dart'; +import 'package:restrr/src/service/api_service.dart'; +import 'package:restrr/src/service/user_service.dart'; import '../restrr.dart'; -class HostInformation { - final Uri? hostUri; - final int apiVersion; - - bool get hasHostUrl => hostUri != null; - - const HostInformation({required this.hostUri, this.apiVersion = 1}); - - const HostInformation.empty() - : hostUri = null, - apiVersion = -1; - - HostInformation copyWith({Uri? hostUri, int? apiVersion}) { - return HostInformation( - hostUri: hostUri ?? this.hostUri, - apiVersion: apiVersion ?? this.apiVersion, - ); - } -} - class RestrrOptions { final bool isWeb; - final CookieJar? cookieJar; - - const RestrrOptions({this.isWeb = false, this.cookieJar}); - - bool get canUseCookieJar => cookieJar != null && !isWeb; + const RestrrOptions({this.isWeb = false}); } -enum RestrrInitType { login, register, refresh } +enum RestrrInitType { login, register, savedSession } /// A builder for creating a new [Restrr] instance. /// The [Restrr] instance is created by calling [create]. @@ -54,12 +32,10 @@ class RestrrBuilder { {required this.uri, required this.username, required this.password, this.email, this.displayName}) : initType = RestrrInitType.register; - RestrrBuilder.refresh({required this.uri}) - : initType = RestrrInitType.refresh; + RestrrBuilder.savedSession({required this.uri}) : initType = RestrrInitType.savedSession; /// Creates a new session with the given [uri]. Future> create() async { - CompiledRoute.cookieJar = options.canUseCookieJar ? options.cookieJar : null; Restrr.log.info('Attempting to initialize a session (${initType.name}) with $uri'); // check if the URI is valid final RestResponse statusResponse = await Restrr.checkUri(uri); @@ -67,63 +43,67 @@ class RestrrBuilder { Restrr.log.warning('Invalid financrr URI: $uri'); return statusResponse.error == RestrrError.unknown ? RestrrError.invalidUri.toRestResponse() - : statusResponse.error?.toRestResponse() ?? RestrrError.invalidUri.toRestResponse(); + : statusResponse.error?.toRestResponse(statusCode: statusResponse.statusCode) ?? + RestrrError.invalidUri.toRestResponse(); } Restrr.log.info('Host: $uri, API v${statusResponse.data!.apiVersion}'); + final RestrrImpl apiImpl = RestrrImpl._( + options: options, routeOptions: RouteOptions(hostUri: uri, apiVersion: statusResponse.data!.apiVersion)); return switch (initType) { - RestrrInitType.register => _handleRegistration(username!, password!, email: email, displayName: displayName), - RestrrInitType.login => _handleLogin(username!, password!), - RestrrInitType.refresh => _handleRefresh(), + RestrrInitType.register => + _handleRegistration(apiImpl, username!, password!, email: email, displayName: displayName), + RestrrInitType.login => _handleLogin(apiImpl, username!, password!), + RestrrInitType.savedSession => _handleSavedSession(apiImpl), }; } - Future> _handleLogin(String username, String password) async { - final RestrrImpl api = RestrrImpl._(options: options); - final RestResponse userResponse = await UserService(api: api).login(username, password); - if (!userResponse.hasData) { + /// 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); + if (!response.hasData) { Restrr.log.warning('Invalid credentials for user $username'); - return RestrrError.invalidCredentials.toRestResponse(); + return RestrrError.invalidCredentials.toRestResponse(statusCode: response.statusCode); } - api.selfUser = userResponse.data!; - Restrr.log.info('Successfully logged in as ${api.selfUser.username}'); - return RestResponse(data: api); + apiImpl.selfUser = response.data!; + Restrr.log.info('Successfully logged in as ${apiImpl.selfUser.username}'); + return RestResponse(data: apiImpl, statusCode: response.statusCode); } - Future> _handleRegistration(String username, String password, + /// Registers a new user and logs in. + Future> _handleRegistration(RestrrImpl apiImpl, String username, String password, {String? email, String? displayName}) async { - final RestrrImpl api = RestrrImpl._(options: options); final RestResponse response = - await UserService(api: api).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() ?? RestrrError.unknown.toRestResponse(); + return response.error?.toRestResponse(statusCode: response.statusCode) ?? RestrrError.unknown.toRestResponse(); } - api.selfUser = response.data!; - Restrr.log.info('Successfully registered & logged in as ${api.selfUser.username}'); - return RestResponse(data: api); + apiImpl.selfUser = response.data!; + Restrr.log.info('Successfully registered & logged in as ${apiImpl.selfUser.username}'); + return RestResponse(data: apiImpl, statusCode: response.statusCode); } - Future> _handleRefresh() async { - final RestrrImpl api = RestrrImpl._(options: options); - final RestResponse response = await UserService(api: api).getSelf(); + /// Attempts to refresh the session with still saved credentials. + Future> _handleSavedSession(RestrrImpl apiImpl) async { + final RestResponse response = await apiImpl.userService.getSelf(); if (response.hasError) { Restrr.log.warning('Failed to refresh session'); - return response.error?.toRestResponse() ?? RestrrError.unknown.toRestResponse(); + return response.error?.toRestResponse(statusCode: response.statusCode) ?? RestrrError.unknown.toRestResponse(); } - api.selfUser = response.data!; - Restrr.log.info('Successfully refreshed session for ${api.selfUser.username}'); - return RestResponse(data: api); + apiImpl.selfUser = response.data!; + Restrr.log.info('Successfully refreshed session for ${apiImpl.selfUser.username}'); + return RestResponse(data: apiImpl, statusCode: response.statusCode); } } abstract class Restrr { static final Logger log = Logger('Restrr'); - static HostInformation hostInformation = HostInformation.empty(); /// Getter for the [EntityBuilder] of this [Restrr] instance. EntityBuilder get entityBuilder; RestrrOptions get options; + RouteOptions get routeOptions; /// The currently authenticated user. User get selfUser; @@ -131,25 +111,24 @@ abstract class Restrr { Future logout(); /// Checks whether the given [uri] is valid and the API is healthy. - static Future> checkUri(Uri uri) async { - hostInformation = hostInformation.copyWith(hostUri: uri, apiVersion: -1); - return ApiService.request( + static Future> checkUri(Uri uri, {bool isWeb = false}) async { + return RequestHandler.request( route: StatusRoutes.health.compile(), - mapper: (json) => EntityBuilder.buildHealthResponse(json)).then((response) { - if (response.hasData && response.data!.healthy) { - // if successful, update the API version - hostInformation = hostInformation.copyWith(apiVersion: response.data!.apiVersion); - } - return response; - }); + mapper: (json) => EntityBuilder.buildHealthResponse(json), + isWeb: isWeb, + routeOptions: RouteOptions(hostUri: uri)); } } class RestrrImpl implements Restrr { @override final RestrrOptions options; + @override + final RouteOptions routeOptions; + + late final UserService userService = UserService(api: this); - RestrrImpl._({required this.options}); + RestrrImpl._({required this.options, required this.routeOptions}); @override late final EntityBuilder entityBuilder = EntityBuilder(api: this); @@ -160,8 +139,8 @@ class RestrrImpl implements Restrr { @override Future logout() async { final RestResponse response = await UserService(api: this).logout(); - if (response.hasData && response.data! && options.canUseCookieJar) { - await CompiledRoute.cookieJar?.deleteAll(); + if (response.hasData && response.data! && !options.isWeb) { + await CompiledRoute.cookieJar.deleteAll(); return true; } return false; diff --git a/lib/src/service/api_service.dart b/lib/src/service/api_service.dart index efb938a..d0de582 100644 --- a/lib/src/service/api_service.dart +++ b/lib/src/service/api_service.dart @@ -1,10 +1,11 @@ import 'package:dio/dio.dart'; import 'package:restrr/restrr.dart'; -abstract class ApiService { - final Restrr api; +import '../requests/route.dart'; - const ApiService({required this.api}); +/// Utility class for handling requests. +class RequestHandler { + const RequestHandler._(); /// Tries to execute a request, using the [CompiledRoute] and maps the received data using the /// specified [mapper] function, ultimately returning the entity in an [RestResponse]. @@ -13,14 +14,17 @@ abstract class ApiService { static Future> request( {required CompiledRoute route, required T Function(dynamic) mapper, + required RouteOptions routeOptions, + bool isWeb = false, Map errorMap = const {}, dynamic body, String contentType = 'application/json'}) async { try { - final Response response = await route.submit(body: body, contentType: contentType); - return RestResponse(data: mapper.call(response.data)); + final Response response = + await route.submit(routeOptions: routeOptions, body: body, contentType: contentType); + return RestResponse(data: mapper.call(response.data), statusCode: response.statusCode); } on DioException catch (e) { - return _handleDioException(e, errorMap); + return _handleDioException(e, isWeb, errorMap); } } @@ -29,14 +33,17 @@ abstract class ApiService { /// If this fails, this will return an [RestResponse] containing an error. static Future> noResponseRequest( {required CompiledRoute route, + required RouteOptions routeOptions, + bool isWeb = false, dynamic body, Map errorMap = const {}, String contentType = 'application/json'}) async { try { - await route.submit(body: body, contentType: contentType); - return const RestResponse(data: true); + final Response response = + await route.submit(routeOptions: routeOptions, body: body, contentType: contentType); + return RestResponse(data: true, statusCode: response.statusCode); } on DioException catch (e) { - return _handleDioException(e, errorMap); + return _handleDioException(e, isWeb, errorMap); } } @@ -46,33 +53,39 @@ abstract class ApiService { /// If this fails, this will return an [RestResponse] containing an error. static Future>> multiRequest( {required CompiledRoute route, + required RouteOptions routeOptions, + bool isWeb = false, required T Function(dynamic) mapper, Map errorMap = const {}, Function(String)? fullRequest, dynamic body, String contentType = 'application/json'}) async { try { - final Response response = await route.submit(body: body, contentType: contentType); + final Response response = + await route.submit(routeOptions: routeOptions, body: body, contentType: contentType); if (response.data is! List) { throw StateError('Received response is not a list!'); } fullRequest?.call(response.data.toString()); - return RestResponse(data: (response.data as List).map((single) => mapper.call(single)).toList()); + return RestResponse( + data: (response.data as List).map((single) => mapper.call(single)).toList(), + statusCode: response.statusCode); } on DioException catch (e) { - return _handleDioException(e, errorMap); + return _handleDioException(e, isWeb, errorMap); } } - static Future> _handleDioException(DioException ex, Map errorMap) async { + static Future> _handleDioException( + DioException ex, bool isWeb, Map errorMap) async { // check internet connection - if (CompiledRoute.cookieJar != null && !await IOUtils.checkConnection()) { + if (!isWeb && !await IOUtils.checkConnection()) { return RestrrError.noInternetConnection.toRestResponse(); } // check status code final int? statusCode = ex.response?.statusCode; if (statusCode != null) { if (errorMap.containsKey(statusCode)) { - return errorMap[statusCode]!.toRestResponse(); + return errorMap[statusCode]!.toRestResponse(statusCode: statusCode); } final RestrrError? err = switch (statusCode) { 400 => RestrrError.badRequest, @@ -81,14 +94,69 @@ abstract class ApiService { _ => null }; if (err != null) { - return err.toRestResponse(); + return err.toRestResponse(statusCode: statusCode); } } // check timeout if (ex.type == DioExceptionType.connectionTimeout || ex.type == DioExceptionType.receiveTimeout) { - return RestrrError.serverUnreachable.toRestResponse(); + return RestrrError.serverUnreachable.toRestResponse(statusCode: statusCode); } Restrr.log.warning('Unknown error occurred: ${ex.message}, ${ex.stackTrace}'); - return RestrrError.unknown.toRestResponse(); + return RestrrError.unknown.toRestResponse(statusCode: statusCode); + } +} + +/// A service that provides methods to interact with the API. +abstract class ApiService { + final Restrr api; + + const ApiService({required this.api}); + + Future> request( + {required CompiledRoute route, + required T Function(dynamic) mapper, + Map errorMap = const {}, + dynamic body, + String contentType = 'application/json'}) async { + return RequestHandler.request( + route: route, + routeOptions: api.routeOptions, + isWeb: api.options.isWeb, + mapper: mapper, + errorMap: errorMap, + body: body, + contentType: contentType); + } + + Future> noResponseRequest( + {required CompiledRoute route, + dynamic body, + Map errorMap = const {}, + String contentType = 'application/json'}) async { + return RequestHandler.noResponseRequest( + route: route, + routeOptions: api.routeOptions, + isWeb: api.options.isWeb, + body: body, + errorMap: errorMap, + contentType: contentType); + } + + Future>> multiRequest( + {required CompiledRoute route, + required T Function(dynamic) mapper, + Map errorMap = const {}, + Function(String)? fullRequest, + dynamic body, + String contentType = 'application/json'}) async { + return RequestHandler.multiRequest( + route: route, + routeOptions: api.routeOptions, + isWeb: api.options.isWeb, + mapper: mapper, + errorMap: errorMap, + fullRequest: fullRequest, + body: body, + contentType: contentType); } } diff --git a/lib/src/service/user_service.dart b/lib/src/service/user_service.dart index 833eabe..b592ad9 100644 --- a/lib/src/service/user_service.dart +++ b/lib/src/service/user_service.dart @@ -1,10 +1,11 @@ import '../../restrr.dart'; +import 'api_service.dart'; class UserService extends ApiService { const UserService({required super.api}); Future> login(String username, String password) async { - return ApiService.request( + return request( route: UserRoutes.login.compile(), body: { 'username': username, @@ -17,13 +18,13 @@ class UserService extends ApiService { } Future> logout() async { - return ApiService.noResponseRequest(route: UserRoutes.logout.compile(), errorMap: { + return noResponseRequest(route: UserRoutes.logout.compile(), errorMap: { 401: RestrrError.notSignedIn, }); } Future> register(String username, String password, {String? email, String? displayName}) async { - return ApiService.request( + return request( route: UserRoutes.register.compile(), body: { 'username': username, @@ -38,11 +39,8 @@ class UserService extends ApiService { } Future> getSelf() async { - return ApiService.request( - route: UserRoutes.me.compile(), - mapper: (json) => api.entityBuilder.buildUser(json), - errorMap: { - 401: RestrrError.notSignedIn, - }); + return request(route: UserRoutes.me.compile(), mapper: (json) => api.entityBuilder.buildUser(json), errorMap: { + 401: RestrrError.notSignedIn, + }); } } diff --git a/pubspec.yaml b/pubspec.yaml index a921a5b..7adb9f8 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.3.3 +version: 0.4.0 repository: https://github.com/financrr/restrr environment: