diff --git a/example/main.dart b/example/main.dart index b0844dd..fdfbc88 100644 --- a/example/main.dart +++ b/example/main.dart @@ -53,8 +53,17 @@ class ExampleEventListener implements SturdyHttpEventListener { print('decoding error'); case AuthFailure(): print('auth failure'); - case MutativeRequestSuccess(): - print('mutative request success'); + case RequestCompleted( + :final headers, + :final statusCode, + :final isSuccess, + :final shouldTriggerDataMutation, + ): + print('request completed: $statusCode, success: $isSuccess'); + print('headers: $headers'); + if (shouldTriggerDataMutation) { + print('mutative request success'); + } } } } diff --git a/lib/src/sturdy_http.dart b/lib/src/sturdy_http.dart index 62bccba..db4c904 100644 --- a/lib/src/sturdy_http.dart +++ b/lib/src/sturdy_http.dart @@ -181,6 +181,7 @@ class SturdyHttp { } } } on DioException catch (error) { + dioResponse = error.response; switch (error.response?.statusCode) { case 401: await _onEvent(AuthFailure(request: error.requestOptions)); @@ -239,9 +240,18 @@ class SturdyHttp { response = await send(request); } - if (response.$2.isSuccess && request.shouldTriggerDataMutation) { + final dioResponse = response.$1; + if (dioResponse != null) { await _onEvent( - MutativeRequestSuccess(request: response.$1!.requestOptions), + RequestCompleted( + request: dioResponse.requestOptions, + headers: dioResponse.headers.map.map( + (key, values) => MapEntry(key, values.join(', ')), + ), + statusCode: dioResponse.statusCode, + isSuccess: response.$2.isSuccess, + shouldTriggerDataMutation: request.shouldTriggerDataMutation, + ), ); } diff --git a/lib/src/sturdy_http_event_listener.dart b/lib/src/sturdy_http_event_listener.dart index 6257231..ee98f61 100644 --- a/lib/src/sturdy_http_event_listener.dart +++ b/lib/src/sturdy_http_event_listener.dart @@ -49,12 +49,31 @@ final class AuthFailure extends SturdyHttpEvent { AuthFailure({required super.request}); } -/// {@template mutative_request_success} -/// Indicates that a "mutative" request succeeded and the data on the client -/// likely does not match the data on the server. -/// See [NetworkRequest.shouldTriggerDataMutation]. +/// {@template request_completed} +/// Indicates that a network request has completed and a response was received. +/// This event fires for all requests that receive any HTTP response, +/// regardless of success or error status. /// {@endtemplate} -final class MutativeRequestSuccess extends SturdyHttpEvent { - /// {@macro mutative_request_success} - MutativeRequestSuccess({required super.request}); +final class RequestCompleted extends SturdyHttpEvent { + /// The response headers. + final Map headers; + + /// The HTTP status code of the response, if available. + final int? statusCode; + + /// Whether the response was successful (2xx status code). + final bool isSuccess; + + /// Whether the request was marked as mutative (data-changing). + /// See [NetworkRequest.shouldTriggerDataMutation]. + final bool shouldTriggerDataMutation; + + /// {@macro request_completed} + RequestCompleted({ + required super.request, + required this.headers, + required this.statusCode, + required this.isSuccess, + required this.shouldTriggerDataMutation, + }); } diff --git a/test/src/network_request_test.dart b/test/src/network_request_test.dart new file mode 100644 index 0000000..8626582 --- /dev/null +++ b/test/src/network_request_test.dart @@ -0,0 +1,33 @@ +import 'package:sturdy_http/sturdy_http.dart'; +import 'package:test/test.dart'; + +void main() { + group('NetworkRequest', () { + group('shouldTriggerDataMutation defaults', () { + test('GetRequest defaults to false', () { + const request = GetRequest('/path'); + expect(request.shouldTriggerDataMutation, isFalse); + }); + + test('PostRequest defaults to true', () { + final request = PostRequest('/path', data: JsonRequestBody({})); + expect(request.shouldTriggerDataMutation, isTrue); + }); + + test('PutRequest defaults to true', () { + final request = PutRequest('/path', data: JsonRequestBody({})); + expect(request.shouldTriggerDataMutation, isTrue); + }); + + test('DeleteRequest defaults to true', () { + const request = DeleteRequest('/path'); + expect(request.shouldTriggerDataMutation, isTrue); + }); + + test('RawRequest defaults to true', () { + const request = RawRequest('/path', type: NetworkRequestType.Post); + expect(request.shouldTriggerDataMutation, isTrue); + }); + }); + }); +} diff --git a/test/src/sturdy_http_event_listener_test.dart b/test/src/sturdy_http_event_listener_test.dart new file mode 100644 index 0000000..fcb2e25 --- /dev/null +++ b/test/src/sturdy_http_event_listener_test.dart @@ -0,0 +1,321 @@ +import 'package:charlatan/charlatan.dart'; +import 'package:dio/dio.dart'; +import 'package:sturdy_http/sturdy_http.dart'; +import 'package:test/test.dart'; + +void main() { + group('SturdyHttpEventListener', () { + group('RequestCompleted', () { + test('emits for successful GET request', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/test', + (request) => CharlatanHttpResponse(body: {'data': 'value'}), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + const GetRequest('/test'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.request.path, '/test'); + expect(events.first.statusCode, 200); + expect(events.first.isSuccess, isTrue); + expect(events.first.shouldTriggerDataMutation, isFalse); + }); + + test( + 'emits for successful POST request with shouldTriggerDataMutation true', + () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenPost( + '/create', + (request) => CharlatanHttpResponse(body: {'id': 1}), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + PostRequest('/create', data: JsonRequestBody({'name': 'test'})), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.request.path, '/create'); + expect(events.first.statusCode, 200); + expect(events.first.isSuccess, isTrue); + expect(events.first.shouldTriggerDataMutation, isTrue); + }, + ); + + test('emits for error responses', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/not-found', + (request) => CharlatanHttpResponse(statusCode: 404), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + const GetRequest('/not-found'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.request.path, '/not-found'); + expect(events.first.statusCode, 404); + expect(events.first.isSuccess, isFalse); + }); + + test('emits for 401 responses alongside AuthFailure', () async { + final charlatan = Charlatan(); + final requestCompletedEvents = []; + final authFailureEvents = []; + + charlatan.whenGet( + '/unauthorized', + (request) => CharlatanHttpResponse(statusCode: 401), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener( + onRequestCompleted: requestCompletedEvents.add, + onAuthFailure: authFailureEvents.add, + ), + ); + + await client.execute( + const GetRequest('/unauthorized'), + onResponse: (_) {}, + ); + + expect(requestCompletedEvents, hasLength(1)); + expect(requestCompletedEvents.first.statusCode, 401); + expect(requestCompletedEvents.first.isSuccess, isFalse); + + expect(authFailureEvents, hasLength(1)); + expect(authFailureEvents.first.path, '/unauthorized'); + }); + + group('headers', () { + test('includes all response headers', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/with-headers', + (request) => CharlatanHttpResponse( + body: {'data': 'value'}, + headers: { + 'X-Custom-Header': 'custom-value', + 'X-Another-Header': 'another-value', + }, + ), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + const GetRequest('/with-headers'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.headers['X-Custom-Header'], 'custom-value'); + expect(events.first.headers['X-Another-Header'], 'another-value'); + }); + + test('includes headers from error responses', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/error-with-headers', + (request) => CharlatanHttpResponse( + statusCode: 500, + headers: {'X-Error-Id': 'error-123'}, + ), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + const GetRequest('/error-with-headers'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.statusCode, 500); + expect(events.first.isSuccess, isFalse); + expect(events.first.headers['X-Error-Id'], 'error-123'); + }); + }); + + group('shouldTriggerDataMutation', () { + test('passes through true from request', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenPost('/post', (request) => CharlatanHttpResponse()); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + PostRequest( + '/post', + data: JsonRequestBody({}), + shouldTriggerDataMutation: true, + ), + onResponse: (_) {}, + ); + + expect(events.first.shouldTriggerDataMutation, isTrue); + }); + + test('passes through false from request', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenPost('/post', (request) => CharlatanHttpResponse()); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onRequestCompleted: events.add), + ); + + await client.execute( + PostRequest( + '/post', + data: JsonRequestBody({}), + shouldTriggerDataMutation: false, + ), + onResponse: (_) {}, + ); + + expect(events.first.shouldTriggerDataMutation, isFalse); + }); + }); + }); + + group('AuthFailure', () { + test('emits for 401 responses', () async { + final charlatan = Charlatan(); + final events = []; + + charlatan.whenGet( + '/unauthorized', + (request) => CharlatanHttpResponse(statusCode: 401), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener(onAuthFailure: events.add), + ); + + await client.execute( + const GetRequest('/unauthorized'), + onResponse: (_) {}, + ); + + expect(events, hasLength(1)); + expect(events.first.path, '/unauthorized'); + }); + }); + + group('DecodingError', () { + test('emits when onResponse throws', () async { + final charlatan = Charlatan(); + final events = <(RequestOptions, Exception, StackTrace?)>[]; + + charlatan.whenGet( + '/test', + (request) => CharlatanHttpResponse(body: {'data': 'value'}), + ); + + final client = SturdyHttp( + baseUrl: 'http://example.com', + customAdapter: charlatan.toFakeHttpClientAdapter(), + eventListener: _TestEventListener( + onDecodingError: (request, exception, stackTrace) { + events.add((request, exception, stackTrace)); + }, + ), + ); + + await expectLater( + () => client.execute( + const GetRequest('/test'), + onResponse: (_) => throw Exception('Decoding failed'), + ), + throwsException, + ); + + expect(events, hasLength(1)); + expect(events.first.$1.path, '/test'); + expect(events.first.$2.toString(), contains('Decoding failed')); + }); + }); + }); +} + +class _TestEventListener extends SturdyHttpEventListener { + final void Function(RequestCompleted)? onRequestCompleted; + final void Function(RequestOptions)? onAuthFailure; + final void Function(RequestOptions, Exception, StackTrace?)? onDecodingError; + + _TestEventListener({ + this.onRequestCompleted, + this.onAuthFailure, + this.onDecodingError, + }); + + @override + Future onEvent(SturdyHttpEvent event) async { + switch (event) { + case RequestCompleted(): + onRequestCompleted?.call(event); + case AuthFailure(:final request): + onAuthFailure?.call(request); + case DecodingError(:final request, :final exception, :final stackTrace): + onDecodingError?.call(request, exception, stackTrace); + } + } +} diff --git a/test/src/sturdy_http_test.dart b/test/src/sturdy_http_test.dart index 0378cb6..08e9dd6 100644 --- a/test/src/sturdy_http_test.dart +++ b/test/src/sturdy_http_test.dart @@ -972,8 +972,8 @@ void main() { maxRetries: 2, retryInterval: Duration(milliseconds: 100), retryClause: (r) { - // Body will be `null`; essentially disallow retrying - return r != null; + // Disallow retrying by always returning false + return false; }, ), ), @@ -1049,8 +1049,14 @@ class _SturdyHttpEventListener extends SturdyHttpEventListener { onDecodingError(request, exception, stackTrace); case AuthFailure(:final request): onAuthFailure(request); - case MutativeRequestSuccess(:final request): - onMutativeRequestSuccess(request); + case RequestCompleted( + :final request, + :final isSuccess, + :final shouldTriggerDataMutation, + ): + if (isSuccess && shouldTriggerDataMutation) { + onMutativeRequestSuccess(request); + } } } }