Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(offline_first_with_rest): add request/response callbacks to the RestOfflineQueueClient #447

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ To cache outbound requests, apply `GraphqlOfflineQueueLink` in your GraphqlProvi
```dart
GraphqlProvider(
link: Link.from([
GraphqlOfflineQueueLink(GraphqlRequestSqliteCacheManager('myAppRequestQueue.sqlite')),
GraphqlOfflineQueueLink(
GraphqlRequestSqliteCacheManager('myAppRequestQueue.sqlite'),
// Optionally specify callbacks for queue retries and errors
onReattempt: onReattempt,
onRequestException: onRequestException,
),
HttpLink(endpoint)
]),
);
Expand Down
9 changes: 9 additions & 0 deletions docs/offline_first/offline_queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,12 @@ final link = GraphqlOfflineQueueLink(
![OfflineQueue logic flow](https://user-images.githubusercontent.com/865897/72175823-f44a3580-3391-11ea-8961-bbeccd74fe7b.jpg)

!> The queue ignores requests that are not `DELETE`, `PATCH`, `POST`, and `PUT` for REST. In GraphQL, `query` and `subscription` operations are ignored. Fetching requests are not worth tracking as the caller may have been disposed by the time the app regains connectivity.

## Queue Processing Callbacks

For tracking the status of queued requests, the `GraphqlOfflineQueueLink` and `RestOfflineQueueClient` constructors accept callback functions which are triggered for specific events:

- `onRequestException`: Invoked when a request encounters an exception during execution (e.g. a `SocketException`). The function receives both the request and an object containing the thrown exception.
- `onReattempt`: Invoked when a request will be retried. For REST, this is when the response has a status code listed in the `reattemptForStatusCodes` list.

These callbacks provide practical ways to detect the state of the queue (e.g. if the client is offline, the queue is being processed, or the queue is blocked during serial processing). For more, refer to the [discussion on this topic](https://github.com/GetDutchie/brick/issues/393).
5 changes: 5 additions & 0 deletions packages/brick_offline_first_with_graphql/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Unreleased

## 3.2.0

- Add optional `onRequestException` callback function to `GraphqlOfflineQueueLink`
- Add optional `onReattempt` callback function to `RestOfflineQueueClient`

## 3.1.1

- Loosen constraints for `gql`, `gql_exec`, and `gql_link`
Expand Down
13 changes: 9 additions & 4 deletions packages/brick_offline_first_with_graphql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ To cache outbound requests, apply `GraphqlOfflineQueueLink` in your GraphqlProvi
```dart
GraphqlProvider(
link: Link.from([
GraphqlOfflineQueueLink(GraphqlRequestSqliteCacheManager('myAppRequestQueue.sqlite')),
GraphqlOfflineQueueLink(
GraphqlRequestSqliteCacheManager('myAppRequestQueue.sqlite'),
// Optionally specify callbacks for queue retries and errors
onReattempt: onReattempt,
onRequestException: onRequestException,
),
HttpLink(endpoint)
]),
);
Expand Down Expand Up @@ -43,9 +48,9 @@ Due to [an open analyzer bug](https://github.com/dart-lang/sdk/issues/38309), a

### Field Types

* Any unsupported field types from `GraphqlProvider` or `SqliteProvider`
* Future iterables of future models (i.e. `Future<List<Future<Model>>>`.
- Any unsupported field types from `GraphqlProvider` or `SqliteProvider`
- Future iterables of future models (i.e. `Future<List<Future<Model>>>`.

### Configuration

* `@OfflineFirst(where:` only supports extremely simple renames. Multiple `where` keys (`OfflineFirst(where: {'id': 'data["id"]', 'otherVar': 'data["otherVar"]'})`) or nested properties (`OfflineFirst(where: {'id': 'data["subfield"]["id"]})`) will be ignored. Be sure to use `@Graphql(name:)` to rename the generated document field.
- `@OfflineFirst(where:` only supports extremely simple renames. Multiple `where` keys (`OfflineFirst(where: {'id': 'data["id"]', 'otherVar': 'data["otherVar"]'})`) or nested properties (`OfflineFirst(where: {'id': 'data["subfield"]["id"]})`) will be ignored. Be sure to use `@Graphql(name:)` to rename the generated document field.
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@ class GraphqlOfflineQueueLink extends Link {

final GraphqlRequestSqliteCacheManager requestManager;

GraphqlOfflineQueueLink(this.requestManager)
: _logger = Logger('GraphqlOfflineQueueLink#${requestManager.databaseName}');
/// A callback triggered when a request failed, but will be reattempted.
final void Function(Request request)? onReattempt;

/// A callback triggered when a request throws an exception during execution.
final void Function(Request request, Object error)? onRequestException;

GraphqlOfflineQueueLink(
this.requestManager, {
this.onReattempt,
this.onRequestException,
}) : _logger = Logger('GraphqlOfflineQueueLink#${requestManager.databaseName}');

@override
Stream<Response> request(Request request, [NextLink? forward]) async* {
Expand All @@ -33,6 +42,7 @@ class GraphqlOfflineQueueLink extends Link {
yield* forward!(request).handleError(
(e) async {
_logger.warning('GraphqlOfflineQueueLink#request: error $e');
onRequestException?.call(request, e);
final db = await requestManager.getDb();
await cacheItem.unlock(db);
},
Expand All @@ -46,7 +56,11 @@ class GraphqlOfflineQueueLink extends Link {
// request was successfully sent and can be removed
_logger.finest('removing from queue: ${cacheItem.toSqlite()}');
await cacheItem.delete(db);
} else if (response.errors != null) {
onRequestException?.call(request, response.errors!);
}

onReattempt?.call(request);
final db = await requestManager.getDb();
await cacheItem.unlock(db);

Expand Down
2 changes: 1 addition & 1 deletion packages/brick_offline_first_with_graphql/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_offline_f
issue_tracker: https://github.com/GetDutchie/brick/issues
repository: https://github.com/GetDutchie/brick

version: 3.1.1
version: 3.2.0

environment:
sdk: ">=2.18.0 <4.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,114 @@ void main() {
});
});

group('#onReattempt', () {
test('callback is triggered when request retries', () async {
Request? capturedRequest;

final mockLink = stubGraphqlLink({}, errors: ['Test failure']);
final client = GraphqlOfflineQueueLink(
requestManager,
onReattempt: (r) => capturedRequest = r,
).concat(mockLink);

final mutationRequest = Request(
operation: Operation(
document: parseString('''mutation {}'''),
operationName: 'fakeMutate',
),
);
await client.request(mutationRequest).first;

expect(capturedRequest, isNotNull);
expect(capturedRequest, mutationRequest);
});

test('callback is not triggered when request succeeds', () async {
Request? capturedRequest;

final mockLink = MockLink();
final client = GraphqlOfflineQueueLink(
requestManager,
onReattempt: (r) => capturedRequest = r,
).concat(mockLink);

when(
mockLink.request(request),
).thenAnswer(
(_) => Stream.fromIterable([response]),
);

client.request(
Request(
operation: Operation(
document: parseString('''mutation {}'''),
),
),
);

expect(capturedRequest, isNull);
});
});

group('#onRequestException', () {
test('callback is triggered for a failed response', () async {
Request? capturedRequest;
Object? capturedonException;

final mockLink = stubGraphqlLink({}, errors: ['Test failure']);
final client = GraphqlOfflineQueueLink(
requestManager,
onRequestException: (request, exception) {
capturedRequest = request;
capturedonException = exception;
},
).concat(mockLink);

final mutationRequest = Request(
operation: Operation(
document: parseString('''mutation {}'''),
operationName: 'fakeMutate',
),
);
await client.request(mutationRequest).first;

expect(capturedRequest, isNotNull);
expect(capturedonException, isNotNull);
expect(capturedonException.toString(), contains('Test failure'));
});

test('callback is not triggered on successful response', () async {
Request? capturedRequest;
Object? capturedException;

final mockLink = MockLink();
final client = GraphqlOfflineQueueLink(
requestManager,
onRequestException: (request, exception) {
capturedRequest = request;
capturedException = exception;
},
).concat(mockLink);

when(
mockLink.request(request),
).thenAnswer(
(_) => Stream.fromIterable([response]),
);

client.request(
Request(
operation: Operation(
document: parseString('''mutation {}'''),
),
),
);

expect(capturedRequest, isNull);
expect(capturedException, isNull);
});
});

test('request deletes after a successful response', () async {
final mockLink = MockLink();
final client = GraphqlOfflineQueueLink(requestManager).concat(mockLink);
Expand Down
5 changes: 5 additions & 0 deletions packages/brick_offline_first_with_rest/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Unreleased

## 3.2.0

- Add optional `onRequestException` callback function to `RestOfflineQueueClient`
- Add optional `onReattempt` callback function to `RestOfflineQueueClient`

## 3.1.0

- Expose offline queue functionality in `offline_queue.dart`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ abstract class OfflineFirstWithRestRepository
/// This property is forwarded to `RestOfflineQueueClient` and assumes
/// its defaults
List<int>? reattemptForStatusCodes,

/// A callback triggered when the response of a request has a status code
/// which is present in the [reattemptForStatusCodes] list.
///
/// Forwarded to [RestOfflineQueueClient].
void Function(http.Request request, int statusCode)? onReattempt,

/// A callback triggered when a request throws an exception during execution.
///
/// Forwarded to [RestOfflineQueueClient].
void Function(http.Request, Object)? onRequestException,
required RestProvider restProvider,
required super.sqliteProvider,
}) : remoteProvider = restProvider,
Expand All @@ -56,6 +67,8 @@ abstract class OfflineFirstWithRestRepository
remoteProvider.client = RestOfflineQueueClient(
restProvider.client,
offlineQueueManager,
onReattempt: onReattempt,
onRequestException: onRequestException,
reattemptForStatusCodes: reattemptForStatusCodes,
);
offlineRequestQueue = RestOfflineRequestQueue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ class RestOfflineQueueClient extends http.BaseClient {
/// as detailed by [the Dart team](https://github.com/dart-lang/http/blob/378179845420caafbf7a34d47b9c22104753182a/README.md#using)
final http.Client _inner;

/// A callback triggered when the response of a request has a status code
/// which is present in the [reattemptForStatusCodes] list.
void Function(http.Request request, int statusCode)? onReattempt;

/// A callback triggered when a request throws an exception during execution.
devj3ns marked this conversation as resolved.
Show resolved Hide resolved
///
/// `SocketException`s (errors thrown due to missing connectivity) will also be forwarded to this callback.
void Function(http.Request request, Object error)? onRequestException;

final RequestSqliteCacheManager<http.Request> requestManager;

/// If the response returned from the client is one of these error codes, the request
Expand All @@ -37,6 +46,8 @@ class RestOfflineQueueClient extends http.BaseClient {
RestOfflineQueueClient(
this._inner,
this.requestManager, {
this.onReattempt,
this.onRequestException,
List<int>? reattemptForStatusCodes,

/// Any request URI that begins with one of these paths will not be
Expand Down Expand Up @@ -89,17 +100,27 @@ class RestOfflineQueueClient extends http.BaseClient {
// Attempt to make HTTP Request
final resp = await _inner.send(request);

if (cacheItem.requestIsPush && !reattemptForStatusCodes.contains(resp.statusCode)) {
final db = await requestManager.getDb();
// request was successfully sent and can be removed
_logger.finest('removing from queue: ${cacheItem.toSqlite()}');
await cacheItem.delete(db);
if (cacheItem.requestIsPush) {
if (!reattemptForStatusCodes.contains(resp.statusCode)) {
final db = await requestManager.getDb();
// request was successfully sent and can be removed
_logger.finest('removing from queue: ${cacheItem.toSqlite()}');
await cacheItem.delete(db);
} else if (onReattempt != null) {
_logger.finest(
'request failed, will be reattempted: ${cacheItem.toSqlite()}',
);
onReattempt?.call(request, resp.statusCode);
}
}

return resp;
} catch (e) {
// e.g. SocketExceptions will be caught here
onRequestException?.call(request, e);
_logger.warning('#send: $e');
} finally {
// unlock the request for a later a reattempt
final db = await requestManager.getDb();
await cacheItem.unlock(db);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/brick_offline_first_with_rest/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_offline_f
issue_tracker: https://github.com/GetDutchie/brick/issues
repository: https://github.com/GetDutchie/brick

version: 3.1.0
version: 3.1.1

environment:
sdk: ">=2.18.0 <4.0.0"
Expand Down
Loading