Skip to content

Commit

Permalink
authentication: tests & cleanup
Browse files Browse the repository at this point in the history
- fix test names & clean up tests
- changed tests to generate nice mocks
- add test util for matching against a request's auth header
- remove the need for GetIt mocking in retry_authenticator_test
- changed throttler extension to return task instead of future
- add withMinimumDuration extension method on Task
- add getAuthenticationToken method in repository
- refactor AuthenticationInterceptor.onRequest
- hugely simplify RetryAuthenticator
- add withBearerToken extension method on Request
- rename repository.dart to repository_test.dart
  • Loading branch information
marfavi committed Feb 4, 2024
1 parent d74d4dc commit 52ef6a6
Show file tree
Hide file tree
Showing 15 changed files with 314 additions and 322 deletions.
14 changes: 14 additions & 0 deletions lib/core/extensions/task_extensions.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:fpdart/fpdart.dart';

extension TaskX<A> on Task<A> {
/// Ensure that this task takes at least [duration] to complete.
///
/// If the task completes before [duration], the returned task will
/// wait for the remaining time before returning the result.
Task<A> withMinimumDuration(Duration duration) => Task(() async {
final minimumDurationTask = Future.delayed(duration);
final result = await run();
await minimumDurationTask;
return result;
});
}
12 changes: 9 additions & 3 deletions lib/core/throttler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import 'package:fpdart/fpdart.dart';

/// A utility class for throttling the execution of asynchronous tasks.
///
/// This class allows you to limit the rate at which a task is executed by
/// {@template throttler}
/// A [Throttler] allows you to limit the rate at which a task is executed by
/// ensuring that only one task runs through the [Throttler] at any given time.
/// If a task is already running, subsequent calls to execute a task will
/// instead return the currently running task.
Expand Down Expand Up @@ -36,7 +37,11 @@ import 'package:fpdart/fpdart.dart';
/// // 5
/// }
/// ```
/// {@endtemplate}
class Throttler<T> {
/// Creates a new [Throttler] instance for [Future]s that return a [T].
///
/// {@macro throttler}
Throttler();

Future<T>? _storedTask;
Expand All @@ -52,9 +57,10 @@ class Throttler<T> {
}

extension ThrottlerX<T> on Task<T> {
/// Throttles this task using the given [throttler].
/// Adds the given [throttler] to this task.
///
/// If no task is currently running through the [throttler], starts this task
/// and stores it. Otherwise, returns the currently running task.
Future<T> runThrottled(Throttler<T> throttler) => throttler.throttle(this);
Task<T> throttleWith(Throttler<T> throttler) =>
Task(() => throttler.throttle(this));
}
6 changes: 5 additions & 1 deletion lib/service_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,11 @@ void initRegister() {
void initHttp() {
ignoreValue(
sl.registerSingleton<RetryAuthenticator>(
RetryAuthenticator.uninitialized(serviceLocator: sl),
RetryAuthenticator.uninitialized(
repository: sl(),
cubit: sl(),
logger: sl(),
),
),
);

Expand Down
1 change: 1 addition & 0 deletions lib/src/authentication/authentication.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export 'bloc/authentication_cubit.dart';
export 'http_utils/authentication_interceptor.dart';
export 'http_utils/retry_authenticator.dart';
export 'http_utils/with_bearer_token.dart';
export 'models/authentication_info.dart';
export 'repository.dart';
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,11 @@ class AuthenticationInterceptor implements chopper.RequestInterceptor {
final AuthenticationRepository repository;

@override
FutureOr<chopper.Request> onRequest(chopper.Request request) {
return repository.getAuthenticationInfo().match(
() => request,
(authenticationInfo) {
final updatedHeaders = Map.of(request.headers);
updatedHeaders['Authorization'] = 'Bearer ${authenticationInfo.token}';
return request.copyWith(headers: updatedHeaders);
},
).run();
FutureOr<chopper.Request> onRequest(chopper.Request originalRequest) {
return repository
.getAuthenticationToken()
.map(originalRequest.withBearerToken)
.getOrElse(() => originalRequest)
.run();
}
}
101 changes: 48 additions & 53 deletions lib/src/authentication/http_utils/retry_authenticator.dart
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import 'dart:async';

import 'package:chopper/chopper.dart' as chopper;
import 'package:chopper/chopper.dart';
import 'package:coffeecard/core/extensions/task_extensions.dart';
import 'package:coffeecard/core/throttler.dart';
import 'package:coffeecard/features/authentication.dart';
import 'package:coffeecard/features/login/data/datasources/account_remote_data_source.dart';
import 'package:coffeecard/generated/api/coffeecard_api.models.swagger.dart'
show LoginDto;
import 'package:fpdart/fpdart.dart';
import 'package:get_it/get_it.dart';
import 'package:logger/logger.dart';

class RetryAuthenticator extends chopper.Authenticator {
class RetryAuthenticator extends Authenticator {
/// Creates a new [RetryAuthenticator] instance.
///
/// This instance is not ready to be used. Call [initialize] before using it.
RetryAuthenticator.uninitialized({required GetIt serviceLocator})
: _repository = serviceLocator(),
_cubit = serviceLocator(),
_logger = serviceLocator();
RetryAuthenticator.uninitialized({
required AuthenticationRepository repository,
required AuthenticationCubit cubit,
required Logger logger,
}) : _repository = repository,
_cubit = cubit,
_logger = logger;

final AuthenticationRepository _repository;
final AuthenticationCubit _cubit;
Expand All @@ -26,30 +29,30 @@ class RetryAuthenticator extends chopper.Authenticator {
// Will be set by [initialize].
late final AccountRemoteDataSource _accountRemoteDataSource;

final _throttler = Throttler<Option<AuthenticationInfo>>();
bool _initialized = false;
final _initializationCompleter = Completer<void>();
final _throttler = Throttler<Request?>();

/// Initializes the [RetryAuthenticator] by providing the
/// [AccountRemoteDataSource] to use.
///
/// This method must be called before the [RetryAuthenticator] is used.
void initialize(AccountRemoteDataSource accountRemoteDataSource) {
_initialized = true;
_initializationCompleter.complete();
_accountRemoteDataSource = accountRemoteDataSource;
}

@override
Future<chopper.Request?> authenticate(
chopper.Request request,
chopper.Response response, [
chopper.Request? _,
]) {
// If the [ReactivationAuthenticator] is not ready, an error is thrown.
assert(
_initialized,
'ReactivationAuthenticator is not ready. '
'Call initialize() before using it.',
);
Future<Request?> authenticate(
Request request,
Response response, [
Request? _,
]) async {
if (!_initializationCompleter.isCompleted) {
throw StateError(
'This RetryAuthenticator is not ready. '
'Call initialize() before using it.',
);
}

// If the response is not unauthorized, we don't need to do anything.
if (response.statusCode != 401) {
Expand All @@ -63,33 +66,21 @@ class RetryAuthenticator extends chopper.Authenticator {
return Future.value();
}

// Try to refresh the token.
final maybeNewToken = Task(() async {
// Set a minimum duration for the token refresh to allow for throttling.
final minimumDuration = Future.delayed(const Duration(milliseconds: 250));

final maybeNewAuthenticationInfo = await _retryLogin(request).run();
await minimumDuration;

// Side effect: save the new token or evict the user.
final _ = maybeNewAuthenticationInfo.match(
_evictUser,
_saveNewAuthenticationInfo,
);
return maybeNewAuthenticationInfo;
}).runThrottled(_throttler);

final maybeNewRequest = TaskOption(() => maybeNewToken).match(
() => null,
(info) => request..headers['Authorization'] = 'Bearer ${info.token}',
);

return maybeNewRequest.run();
return _retryLogin(request)
.match(
() => _onRetryFailed(),
(newAuthInfo) => _onRetrySucceeded(newAuthInfo, request),
)
// Flatten; transform the Task<Task<Request?>> into a Task<Request?>.
.flatMap(identity)
.withMinimumDuration(const Duration(milliseconds: 250))
.throttleWith(_throttler)
.run();
}

/// Attempt to retrieve new [AuthenticationInfo] by logging in with the
/// stored credentials.
TaskOption<AuthenticationInfo> _retryLogin(chopper.Request request) {
TaskOption<AuthenticationInfo> _retryLogin(Request request) {
_logger.d(
'Token refresh triggered by request:\n\t${request.method} ${request.url}',
);
Expand All @@ -108,16 +99,20 @@ class RetryAuthenticator extends chopper.Authenticator {
);
}

Task<Unit> _saveNewAuthenticationInfo(AuthenticationInfo newInfo) {
return _repository
.saveAuthenticationInfo(newInfo)
.map((_) => _logger.d('Successfully refreshed token.'))
.map((_) => unit);
}

Task<Unit> _evictUser() {
/// Handles the side effects of a failed token refresh and returns null.
Task<Request?> _onRetryFailed() {
_logger.e('Failed to refresh token. Signing out.');
_cubit.unauthenticated();
return Task.of(unit);
return _repository.clearAuthenticationInfo().map((_) => null);
}

/// Handles the side effects of a successful token refresh and returns the
/// new [Request] with the updated token.
Task<Request> _onRetrySucceeded(AuthenticationInfo newInfo, Request req) {
_logger.d('Successfully refreshed token. Retrying request.');

return _repository
.saveAuthenticationInfo(newInfo)
.map((_) => req.withBearerToken(newInfo.token));
}
}
8 changes: 8 additions & 0 deletions lib/src/authentication/http_utils/with_bearer_token.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:chopper/chopper.dart';

extension RequestX on Request {
/// Returns a copy of this [Request] with the given [token] added to the
/// `Authorization` header.
Request withBearerToken(String token) =>
this..headers['Authorization'] = 'Bearer $token';
}
4 changes: 4 additions & 0 deletions lib/src/authentication/repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class AuthenticationRepository {
return store.getAsTaskOption(_authenticationInfoKey);
}

TaskOption<String> getAuthenticationToken() {
return getAuthenticationInfo().map((info) => info.token);
}

Task<Unit> clearAuthenticationInfo() {
return store
.clearAsTask()
Expand Down
18 changes: 10 additions & 8 deletions test/core/throttler_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ void main() {
'WHEN a task is throttled '
'THEN it should execute the task',
() async {
await incrementTask.runThrottled(throttler);
await incrementTask.throttleWith(throttler).run();
expect(counter, 1);
},
);
Expand All @@ -40,10 +40,11 @@ void main() {
() async {
// Throttle a task that will wait for the completer to complete,
// and then increment the counter.
final task1 = incrementAndWaitTask(completer).runThrottled(throttler);
final task1 =
incrementAndWaitTask(completer).throttleWith(throttler).run();

// Throttle a task that will increment the counter immediately.
final task2 = incrementTask.runThrottled(throttler);
final task2 = incrementTask.throttleWith(throttler).run();

// Since task2 was run while task1 was still running,
// their futures should be identical.
Expand All @@ -61,13 +62,14 @@ void main() {
() async {
// Throttle a task that will wait for the completer to complete,
// and then increment the counter.
final task1 = incrementAndWaitTask(completer).runThrottled(throttler);
final task1 =
incrementAndWaitTask(completer).throttleWith(throttler).run();

// Throttle another task that will increment the counter immediately.
final task2 = incrementTask.runThrottled(throttler);
final task2 = incrementTask.throttleWith(throttler).run();

// Throttle one more task that will increment the counter immediately.
final task3 = incrementTask.runThrottled(throttler);
final task3 = incrementTask.throttleWith(throttler).run();

// Since task2 and task3 were run while task1 was still running,
// all futures should be identical.
Expand All @@ -89,10 +91,10 @@ void main() {
'THEN it should execute the task',
() async {
// Throttle a task and wait for completion.
await incrementTask.runThrottled(throttler);
await incrementTask.throttleWith(throttler).run();

// Throttle another task and wait for completion.
await incrementTask.runThrottled(throttler);
await incrementTask.throttleWith(throttler).run();

// The counter should have incremented twice.
expect(counter, 2);
Expand Down
Loading

0 comments on commit 52ef6a6

Please sign in to comment.